@topgunbuild/server 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 +643 -3
- package/dist/index.d.ts +643 -3
- package/dist/index.js +2272 -285
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2024 -32
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -11
- package/LICENSE +0 -97
package/dist/index.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { createServer as createHttpServer } from "http";
|
|
|
10
10
|
import { createServer as createHttpsServer } from "https";
|
|
11
11
|
import { readFileSync as readFileSync2 } from "fs";
|
|
12
12
|
import { WebSocketServer as WebSocketServer2, WebSocket as WebSocket3 } from "ws";
|
|
13
|
-
import { HLC, LWWMap as
|
|
13
|
+
import { HLC as HLC2, LWWMap as LWWMap3, ORMap as ORMap2, serialize as serialize4, deserialize, MessageSchema, WriteConcern as WriteConcern2, ConsistencyLevel as ConsistencyLevel2, DEFAULT_REPLICATION_CONFIG as DEFAULT_REPLICATION_CONFIG2 } from "@topgunbuild/core";
|
|
14
14
|
import * as jwt from "jsonwebtoken";
|
|
15
15
|
import * as crypto from "crypto";
|
|
16
16
|
|
|
@@ -6412,6 +6412,1384 @@ var ReplicationPipeline = class extends EventEmitter8 {
|
|
|
6412
6412
|
}
|
|
6413
6413
|
};
|
|
6414
6414
|
|
|
6415
|
+
// src/handlers/CounterHandler.ts
|
|
6416
|
+
import { PNCounterImpl } from "@topgunbuild/core";
|
|
6417
|
+
var CounterHandler = class {
|
|
6418
|
+
// counterName -> Set<clientId>
|
|
6419
|
+
constructor(nodeId = "server") {
|
|
6420
|
+
this.nodeId = nodeId;
|
|
6421
|
+
this.counters = /* @__PURE__ */ new Map();
|
|
6422
|
+
this.subscriptions = /* @__PURE__ */ new Map();
|
|
6423
|
+
}
|
|
6424
|
+
/**
|
|
6425
|
+
* Get or create a counter by name.
|
|
6426
|
+
*/
|
|
6427
|
+
getOrCreateCounter(name) {
|
|
6428
|
+
let counter = this.counters.get(name);
|
|
6429
|
+
if (!counter) {
|
|
6430
|
+
counter = new PNCounterImpl({ nodeId: this.nodeId });
|
|
6431
|
+
this.counters.set(name, counter);
|
|
6432
|
+
logger.debug({ name }, "Created new counter");
|
|
6433
|
+
}
|
|
6434
|
+
return counter;
|
|
6435
|
+
}
|
|
6436
|
+
/**
|
|
6437
|
+
* Handle COUNTER_REQUEST - client wants initial state.
|
|
6438
|
+
* @returns Response message to send back to client
|
|
6439
|
+
*/
|
|
6440
|
+
handleCounterRequest(clientId, name) {
|
|
6441
|
+
const counter = this.getOrCreateCounter(name);
|
|
6442
|
+
this.subscribe(clientId, name);
|
|
6443
|
+
const state = counter.getState();
|
|
6444
|
+
logger.debug({ clientId, name, value: counter.get() }, "Counter request handled");
|
|
6445
|
+
return {
|
|
6446
|
+
type: "COUNTER_RESPONSE",
|
|
6447
|
+
payload: {
|
|
6448
|
+
name,
|
|
6449
|
+
state: this.stateToObject(state)
|
|
6450
|
+
}
|
|
6451
|
+
};
|
|
6452
|
+
}
|
|
6453
|
+
/**
|
|
6454
|
+
* Handle COUNTER_SYNC - client sends their state to merge.
|
|
6455
|
+
* @returns Merged state and list of clients to broadcast to
|
|
6456
|
+
*/
|
|
6457
|
+
handleCounterSync(clientId, name, stateObj) {
|
|
6458
|
+
const counter = this.getOrCreateCounter(name);
|
|
6459
|
+
const incomingState = this.objectToState(stateObj);
|
|
6460
|
+
counter.merge(incomingState);
|
|
6461
|
+
const mergedState = counter.getState();
|
|
6462
|
+
const mergedStateObj = this.stateToObject(mergedState);
|
|
6463
|
+
logger.debug(
|
|
6464
|
+
{ clientId, name, value: counter.get() },
|
|
6465
|
+
"Counter sync handled"
|
|
6466
|
+
);
|
|
6467
|
+
this.subscribe(clientId, name);
|
|
6468
|
+
const subscribers = this.subscriptions.get(name) || /* @__PURE__ */ new Set();
|
|
6469
|
+
const broadcastTo = Array.from(subscribers).filter((id) => id !== clientId);
|
|
6470
|
+
return {
|
|
6471
|
+
// Response to the sending client
|
|
6472
|
+
response: {
|
|
6473
|
+
type: "COUNTER_UPDATE",
|
|
6474
|
+
payload: {
|
|
6475
|
+
name,
|
|
6476
|
+
state: mergedStateObj
|
|
6477
|
+
}
|
|
6478
|
+
},
|
|
6479
|
+
// Broadcast to other clients
|
|
6480
|
+
broadcastTo,
|
|
6481
|
+
broadcastMessage: {
|
|
6482
|
+
type: "COUNTER_UPDATE",
|
|
6483
|
+
payload: {
|
|
6484
|
+
name,
|
|
6485
|
+
state: mergedStateObj
|
|
6486
|
+
}
|
|
6487
|
+
}
|
|
6488
|
+
};
|
|
6489
|
+
}
|
|
6490
|
+
/**
|
|
6491
|
+
* Subscribe a client to counter updates.
|
|
6492
|
+
*/
|
|
6493
|
+
subscribe(clientId, counterName) {
|
|
6494
|
+
if (!this.subscriptions.has(counterName)) {
|
|
6495
|
+
this.subscriptions.set(counterName, /* @__PURE__ */ new Set());
|
|
6496
|
+
}
|
|
6497
|
+
this.subscriptions.get(counterName).add(clientId);
|
|
6498
|
+
logger.debug({ clientId, counterName }, "Client subscribed to counter");
|
|
6499
|
+
}
|
|
6500
|
+
/**
|
|
6501
|
+
* Unsubscribe a client from counter updates.
|
|
6502
|
+
*/
|
|
6503
|
+
unsubscribe(clientId, counterName) {
|
|
6504
|
+
const subs = this.subscriptions.get(counterName);
|
|
6505
|
+
if (subs) {
|
|
6506
|
+
subs.delete(clientId);
|
|
6507
|
+
if (subs.size === 0) {
|
|
6508
|
+
this.subscriptions.delete(counterName);
|
|
6509
|
+
}
|
|
6510
|
+
}
|
|
6511
|
+
}
|
|
6512
|
+
/**
|
|
6513
|
+
* Unsubscribe a client from all counters (e.g., on disconnect).
|
|
6514
|
+
*/
|
|
6515
|
+
unsubscribeAll(clientId) {
|
|
6516
|
+
for (const [counterName, subs] of this.subscriptions) {
|
|
6517
|
+
subs.delete(clientId);
|
|
6518
|
+
if (subs.size === 0) {
|
|
6519
|
+
this.subscriptions.delete(counterName);
|
|
6520
|
+
}
|
|
6521
|
+
}
|
|
6522
|
+
logger.debug({ clientId }, "Client unsubscribed from all counters");
|
|
6523
|
+
}
|
|
6524
|
+
/**
|
|
6525
|
+
* Get current counter value (for monitoring/debugging).
|
|
6526
|
+
*/
|
|
6527
|
+
getCounterValue(name) {
|
|
6528
|
+
const counter = this.counters.get(name);
|
|
6529
|
+
return counter ? counter.get() : 0;
|
|
6530
|
+
}
|
|
6531
|
+
/**
|
|
6532
|
+
* Get all counter names.
|
|
6533
|
+
*/
|
|
6534
|
+
getCounterNames() {
|
|
6535
|
+
return Array.from(this.counters.keys());
|
|
6536
|
+
}
|
|
6537
|
+
/**
|
|
6538
|
+
* Get number of subscribers for a counter.
|
|
6539
|
+
*/
|
|
6540
|
+
getSubscriberCount(name) {
|
|
6541
|
+
return this.subscriptions.get(name)?.size || 0;
|
|
6542
|
+
}
|
|
6543
|
+
/**
|
|
6544
|
+
* Convert Map-based state to plain object for serialization.
|
|
6545
|
+
*/
|
|
6546
|
+
stateToObject(state) {
|
|
6547
|
+
return {
|
|
6548
|
+
p: Object.fromEntries(state.positive),
|
|
6549
|
+
n: Object.fromEntries(state.negative)
|
|
6550
|
+
};
|
|
6551
|
+
}
|
|
6552
|
+
/**
|
|
6553
|
+
* Convert plain object to Map-based state.
|
|
6554
|
+
*/
|
|
6555
|
+
objectToState(obj) {
|
|
6556
|
+
return {
|
|
6557
|
+
positive: new Map(Object.entries(obj.p || {})),
|
|
6558
|
+
negative: new Map(Object.entries(obj.n || {}))
|
|
6559
|
+
};
|
|
6560
|
+
}
|
|
6561
|
+
};
|
|
6562
|
+
|
|
6563
|
+
// src/handlers/EntryProcessorHandler.ts
|
|
6564
|
+
import {
|
|
6565
|
+
EntryProcessorDefSchema
|
|
6566
|
+
} from "@topgunbuild/core";
|
|
6567
|
+
|
|
6568
|
+
// src/ProcessorSandbox.ts
|
|
6569
|
+
import {
|
|
6570
|
+
validateProcessorCode
|
|
6571
|
+
} from "@topgunbuild/core";
|
|
6572
|
+
var ivm = null;
|
|
6573
|
+
try {
|
|
6574
|
+
ivm = __require("isolated-vm");
|
|
6575
|
+
} catch {
|
|
6576
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
6577
|
+
if (isProduction) {
|
|
6578
|
+
logger.error(
|
|
6579
|
+
"SECURITY WARNING: isolated-vm not available in production! Entry processors will run in less secure fallback mode. Install isolated-vm for production environments: pnpm add isolated-vm"
|
|
6580
|
+
);
|
|
6581
|
+
} else {
|
|
6582
|
+
logger.warn("isolated-vm not available, falling back to less secure VM");
|
|
6583
|
+
}
|
|
6584
|
+
}
|
|
6585
|
+
var DEFAULT_SANDBOX_CONFIG = {
|
|
6586
|
+
memoryLimitMb: 8,
|
|
6587
|
+
timeoutMs: 100,
|
|
6588
|
+
maxCachedIsolates: 100,
|
|
6589
|
+
strictValidation: true
|
|
6590
|
+
};
|
|
6591
|
+
var ProcessorSandbox = class {
|
|
6592
|
+
constructor(config = {}) {
|
|
6593
|
+
this.isolateCache = /* @__PURE__ */ new Map();
|
|
6594
|
+
this.scriptCache = /* @__PURE__ */ new Map();
|
|
6595
|
+
this.fallbackScriptCache = /* @__PURE__ */ new Map();
|
|
6596
|
+
this.disposed = false;
|
|
6597
|
+
this.config = { ...DEFAULT_SANDBOX_CONFIG, ...config };
|
|
6598
|
+
}
|
|
6599
|
+
/**
|
|
6600
|
+
* Execute an entry processor in the sandbox.
|
|
6601
|
+
*
|
|
6602
|
+
* @param processor The processor definition (name, code, args)
|
|
6603
|
+
* @param value The current value for the key (or undefined)
|
|
6604
|
+
* @param key The key being processed
|
|
6605
|
+
* @returns Result containing success status, result, and new value
|
|
6606
|
+
*/
|
|
6607
|
+
async execute(processor, value, key) {
|
|
6608
|
+
if (this.disposed) {
|
|
6609
|
+
return {
|
|
6610
|
+
success: false,
|
|
6611
|
+
error: "Sandbox has been disposed"
|
|
6612
|
+
};
|
|
6613
|
+
}
|
|
6614
|
+
if (this.config.strictValidation) {
|
|
6615
|
+
const validation = validateProcessorCode(processor.code);
|
|
6616
|
+
if (!validation.valid) {
|
|
6617
|
+
return {
|
|
6618
|
+
success: false,
|
|
6619
|
+
error: validation.error
|
|
6620
|
+
};
|
|
6621
|
+
}
|
|
6622
|
+
}
|
|
6623
|
+
if (ivm) {
|
|
6624
|
+
return this.executeInIsolate(processor, value, key);
|
|
6625
|
+
} else {
|
|
6626
|
+
return this.executeInFallback(processor, value, key);
|
|
6627
|
+
}
|
|
6628
|
+
}
|
|
6629
|
+
/**
|
|
6630
|
+
* Execute processor in isolated-vm (secure production mode).
|
|
6631
|
+
*/
|
|
6632
|
+
async executeInIsolate(processor, value, key) {
|
|
6633
|
+
if (!ivm) {
|
|
6634
|
+
return { success: false, error: "isolated-vm not available" };
|
|
6635
|
+
}
|
|
6636
|
+
const isolate = this.getOrCreateIsolate(processor.name);
|
|
6637
|
+
try {
|
|
6638
|
+
const context = await isolate.createContext();
|
|
6639
|
+
const jail = context.global;
|
|
6640
|
+
await jail.set("global", jail.derefInto());
|
|
6641
|
+
await context.eval(`
|
|
6642
|
+
var value = ${JSON.stringify(value)};
|
|
6643
|
+
var key = ${JSON.stringify(key)};
|
|
6644
|
+
var args = ${JSON.stringify(processor.args)};
|
|
6645
|
+
`);
|
|
6646
|
+
const wrappedCode = `
|
|
6647
|
+
(function() {
|
|
6648
|
+
${processor.code}
|
|
6649
|
+
})()
|
|
6650
|
+
`;
|
|
6651
|
+
const script = await this.getOrCompileScript(
|
|
6652
|
+
processor.name,
|
|
6653
|
+
wrappedCode,
|
|
6654
|
+
isolate
|
|
6655
|
+
);
|
|
6656
|
+
const result = await script.run(context, {
|
|
6657
|
+
timeout: this.config.timeoutMs
|
|
6658
|
+
});
|
|
6659
|
+
const parsed = result;
|
|
6660
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
6661
|
+
return {
|
|
6662
|
+
success: false,
|
|
6663
|
+
error: "Processor must return { value, result? } object"
|
|
6664
|
+
};
|
|
6665
|
+
}
|
|
6666
|
+
return {
|
|
6667
|
+
success: true,
|
|
6668
|
+
result: parsed.result,
|
|
6669
|
+
newValue: parsed.value
|
|
6670
|
+
};
|
|
6671
|
+
} catch (error) {
|
|
6672
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6673
|
+
if (message.includes("Script execution timed out")) {
|
|
6674
|
+
return {
|
|
6675
|
+
success: false,
|
|
6676
|
+
error: "Processor execution timed out"
|
|
6677
|
+
};
|
|
6678
|
+
}
|
|
6679
|
+
return {
|
|
6680
|
+
success: false,
|
|
6681
|
+
error: message
|
|
6682
|
+
};
|
|
6683
|
+
}
|
|
6684
|
+
}
|
|
6685
|
+
/**
|
|
6686
|
+
* Execute processor in fallback VM (less secure, for development).
|
|
6687
|
+
*/
|
|
6688
|
+
async executeInFallback(processor, value, key) {
|
|
6689
|
+
try {
|
|
6690
|
+
const isResolver = processor.name.startsWith("resolver:");
|
|
6691
|
+
let fn = isResolver ? void 0 : this.fallbackScriptCache.get(processor.name);
|
|
6692
|
+
if (!fn) {
|
|
6693
|
+
const wrappedCode = `
|
|
6694
|
+
return (function(value, key, args) {
|
|
6695
|
+
${processor.code}
|
|
6696
|
+
})
|
|
6697
|
+
`;
|
|
6698
|
+
fn = new Function(wrappedCode)();
|
|
6699
|
+
if (!isResolver) {
|
|
6700
|
+
this.fallbackScriptCache.set(processor.name, fn);
|
|
6701
|
+
}
|
|
6702
|
+
}
|
|
6703
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
6704
|
+
setTimeout(() => reject(new Error("Processor execution timed out")), this.config.timeoutMs);
|
|
6705
|
+
});
|
|
6706
|
+
const executionPromise = Promise.resolve().then(() => fn(value, key, processor.args));
|
|
6707
|
+
const result = await Promise.race([executionPromise, timeoutPromise]);
|
|
6708
|
+
if (typeof result !== "object" || result === null) {
|
|
6709
|
+
return {
|
|
6710
|
+
success: false,
|
|
6711
|
+
error: "Processor must return { value, result? } object"
|
|
6712
|
+
};
|
|
6713
|
+
}
|
|
6714
|
+
return {
|
|
6715
|
+
success: true,
|
|
6716
|
+
result: result.result,
|
|
6717
|
+
newValue: result.value
|
|
6718
|
+
};
|
|
6719
|
+
} catch (error) {
|
|
6720
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6721
|
+
return {
|
|
6722
|
+
success: false,
|
|
6723
|
+
error: message
|
|
6724
|
+
};
|
|
6725
|
+
}
|
|
6726
|
+
}
|
|
6727
|
+
/**
|
|
6728
|
+
* Get or create an isolate for a processor.
|
|
6729
|
+
*/
|
|
6730
|
+
getOrCreateIsolate(name) {
|
|
6731
|
+
if (!ivm) {
|
|
6732
|
+
throw new Error("isolated-vm not available");
|
|
6733
|
+
}
|
|
6734
|
+
let isolate = this.isolateCache.get(name);
|
|
6735
|
+
if (!isolate || isolate.isDisposed) {
|
|
6736
|
+
if (this.isolateCache.size >= this.config.maxCachedIsolates) {
|
|
6737
|
+
const oldest = this.isolateCache.keys().next().value;
|
|
6738
|
+
if (oldest) {
|
|
6739
|
+
const oldIsolate = this.isolateCache.get(oldest);
|
|
6740
|
+
if (oldIsolate && !oldIsolate.isDisposed) {
|
|
6741
|
+
oldIsolate.dispose();
|
|
6742
|
+
}
|
|
6743
|
+
this.isolateCache.delete(oldest);
|
|
6744
|
+
this.scriptCache.delete(oldest);
|
|
6745
|
+
}
|
|
6746
|
+
}
|
|
6747
|
+
isolate = new ivm.Isolate({
|
|
6748
|
+
memoryLimit: this.config.memoryLimitMb
|
|
6749
|
+
});
|
|
6750
|
+
this.isolateCache.set(name, isolate);
|
|
6751
|
+
}
|
|
6752
|
+
return isolate;
|
|
6753
|
+
}
|
|
6754
|
+
/**
|
|
6755
|
+
* Get or compile a script for a processor.
|
|
6756
|
+
*/
|
|
6757
|
+
async getOrCompileScript(name, code, isolate) {
|
|
6758
|
+
let script = this.scriptCache.get(name);
|
|
6759
|
+
if (!script) {
|
|
6760
|
+
script = await isolate.compileScript(code);
|
|
6761
|
+
this.scriptCache.set(name, script);
|
|
6762
|
+
}
|
|
6763
|
+
return script;
|
|
6764
|
+
}
|
|
6765
|
+
/**
|
|
6766
|
+
* Clear script cache for a specific processor (e.g., when code changes).
|
|
6767
|
+
*/
|
|
6768
|
+
clearCache(processorName) {
|
|
6769
|
+
if (processorName) {
|
|
6770
|
+
const isolate = this.isolateCache.get(processorName);
|
|
6771
|
+
if (isolate && !isolate.isDisposed) {
|
|
6772
|
+
isolate.dispose();
|
|
6773
|
+
}
|
|
6774
|
+
this.isolateCache.delete(processorName);
|
|
6775
|
+
this.scriptCache.delete(processorName);
|
|
6776
|
+
this.fallbackScriptCache.delete(processorName);
|
|
6777
|
+
} else {
|
|
6778
|
+
for (const isolate of this.isolateCache.values()) {
|
|
6779
|
+
if (!isolate.isDisposed) {
|
|
6780
|
+
isolate.dispose();
|
|
6781
|
+
}
|
|
6782
|
+
}
|
|
6783
|
+
this.isolateCache.clear();
|
|
6784
|
+
this.scriptCache.clear();
|
|
6785
|
+
this.fallbackScriptCache.clear();
|
|
6786
|
+
}
|
|
6787
|
+
}
|
|
6788
|
+
/**
|
|
6789
|
+
* Check if using secure isolated-vm mode.
|
|
6790
|
+
*/
|
|
6791
|
+
isSecureMode() {
|
|
6792
|
+
return ivm !== null;
|
|
6793
|
+
}
|
|
6794
|
+
/**
|
|
6795
|
+
* Get current cache sizes.
|
|
6796
|
+
*/
|
|
6797
|
+
getCacheStats() {
|
|
6798
|
+
return {
|
|
6799
|
+
isolates: this.isolateCache.size,
|
|
6800
|
+
scripts: this.scriptCache.size,
|
|
6801
|
+
fallbackScripts: this.fallbackScriptCache.size
|
|
6802
|
+
};
|
|
6803
|
+
}
|
|
6804
|
+
/**
|
|
6805
|
+
* Dispose of all isolates and clear caches.
|
|
6806
|
+
*/
|
|
6807
|
+
dispose() {
|
|
6808
|
+
if (this.disposed) return;
|
|
6809
|
+
this.disposed = true;
|
|
6810
|
+
this.clearCache();
|
|
6811
|
+
logger.debug("ProcessorSandbox disposed");
|
|
6812
|
+
}
|
|
6813
|
+
};
|
|
6814
|
+
|
|
6815
|
+
// src/handlers/EntryProcessorHandler.ts
|
|
6816
|
+
var EntryProcessorHandler = class {
|
|
6817
|
+
constructor(config) {
|
|
6818
|
+
this.hlc = config.hlc;
|
|
6819
|
+
this.sandbox = new ProcessorSandbox(config.sandboxConfig);
|
|
6820
|
+
}
|
|
6821
|
+
/**
|
|
6822
|
+
* Execute a processor on a single key atomically.
|
|
6823
|
+
*
|
|
6824
|
+
* @param map The LWWMap to operate on
|
|
6825
|
+
* @param key The key to process
|
|
6826
|
+
* @param processorDef The processor definition (will be validated)
|
|
6827
|
+
* @returns Result with success status, processor result, and new value
|
|
6828
|
+
*/
|
|
6829
|
+
async executeOnKey(map, key, processorDef) {
|
|
6830
|
+
const parseResult = EntryProcessorDefSchema.safeParse(processorDef);
|
|
6831
|
+
if (!parseResult.success) {
|
|
6832
|
+
logger.warn(
|
|
6833
|
+
{ key, error: parseResult.error.message },
|
|
6834
|
+
"Invalid processor definition"
|
|
6835
|
+
);
|
|
6836
|
+
return {
|
|
6837
|
+
result: {
|
|
6838
|
+
success: false,
|
|
6839
|
+
error: `Invalid processor: ${parseResult.error.message}`
|
|
6840
|
+
}
|
|
6841
|
+
};
|
|
6842
|
+
}
|
|
6843
|
+
const processor = parseResult.data;
|
|
6844
|
+
const currentValue = map.get(key);
|
|
6845
|
+
logger.debug(
|
|
6846
|
+
{ key, processor: processor.name, hasValue: currentValue !== void 0 },
|
|
6847
|
+
"Executing entry processor"
|
|
6848
|
+
);
|
|
6849
|
+
const sandboxResult = await this.sandbox.execute(
|
|
6850
|
+
processor,
|
|
6851
|
+
currentValue,
|
|
6852
|
+
key
|
|
6853
|
+
);
|
|
6854
|
+
if (!sandboxResult.success) {
|
|
6855
|
+
logger.warn(
|
|
6856
|
+
{ key, processor: processor.name, error: sandboxResult.error },
|
|
6857
|
+
"Processor execution failed"
|
|
6858
|
+
);
|
|
6859
|
+
return { result: sandboxResult };
|
|
6860
|
+
}
|
|
6861
|
+
let timestamp;
|
|
6862
|
+
if (sandboxResult.newValue !== void 0) {
|
|
6863
|
+
const record = map.set(key, sandboxResult.newValue);
|
|
6864
|
+
timestamp = record.timestamp;
|
|
6865
|
+
logger.debug(
|
|
6866
|
+
{ key, processor: processor.name, timestamp },
|
|
6867
|
+
"Processor updated value"
|
|
6868
|
+
);
|
|
6869
|
+
} else if (currentValue !== void 0) {
|
|
6870
|
+
const tombstone = map.remove(key);
|
|
6871
|
+
timestamp = tombstone.timestamp;
|
|
6872
|
+
logger.debug(
|
|
6873
|
+
{ key, processor: processor.name, timestamp },
|
|
6874
|
+
"Processor deleted value"
|
|
6875
|
+
);
|
|
6876
|
+
}
|
|
6877
|
+
return {
|
|
6878
|
+
result: sandboxResult,
|
|
6879
|
+
timestamp
|
|
6880
|
+
};
|
|
6881
|
+
}
|
|
6882
|
+
/**
|
|
6883
|
+
* Execute a processor on multiple keys.
|
|
6884
|
+
*
|
|
6885
|
+
* Each key is processed sequentially to ensure atomicity per-key.
|
|
6886
|
+
* For parallel execution across keys, use multiple calls.
|
|
6887
|
+
*
|
|
6888
|
+
* @param map The LWWMap to operate on
|
|
6889
|
+
* @param keys The keys to process
|
|
6890
|
+
* @param processorDef The processor definition
|
|
6891
|
+
* @returns Map of key -> result
|
|
6892
|
+
*/
|
|
6893
|
+
async executeOnKeys(map, keys, processorDef) {
|
|
6894
|
+
const results = /* @__PURE__ */ new Map();
|
|
6895
|
+
const timestamps = /* @__PURE__ */ new Map();
|
|
6896
|
+
const parseResult = EntryProcessorDefSchema.safeParse(processorDef);
|
|
6897
|
+
if (!parseResult.success) {
|
|
6898
|
+
const errorResult = {
|
|
6899
|
+
success: false,
|
|
6900
|
+
error: `Invalid processor: ${parseResult.error.message}`
|
|
6901
|
+
};
|
|
6902
|
+
for (const key of keys) {
|
|
6903
|
+
results.set(key, errorResult);
|
|
6904
|
+
}
|
|
6905
|
+
return { results, timestamps };
|
|
6906
|
+
}
|
|
6907
|
+
for (const key of keys) {
|
|
6908
|
+
const { result, timestamp } = await this.executeOnKey(
|
|
6909
|
+
map,
|
|
6910
|
+
key,
|
|
6911
|
+
processorDef
|
|
6912
|
+
);
|
|
6913
|
+
results.set(key, result);
|
|
6914
|
+
if (timestamp) {
|
|
6915
|
+
timestamps.set(key, timestamp);
|
|
6916
|
+
}
|
|
6917
|
+
}
|
|
6918
|
+
return { results, timestamps };
|
|
6919
|
+
}
|
|
6920
|
+
/**
|
|
6921
|
+
* Execute a processor on all entries matching a predicate.
|
|
6922
|
+
*
|
|
6923
|
+
* WARNING: This can be expensive for large maps.
|
|
6924
|
+
*
|
|
6925
|
+
* @param map The LWWMap to operate on
|
|
6926
|
+
* @param processorDef The processor definition
|
|
6927
|
+
* @param predicateCode Optional predicate code to filter entries
|
|
6928
|
+
* @returns Map of key -> result for processed entries
|
|
6929
|
+
*/
|
|
6930
|
+
async executeOnEntries(map, processorDef, predicateCode) {
|
|
6931
|
+
const results = /* @__PURE__ */ new Map();
|
|
6932
|
+
const timestamps = /* @__PURE__ */ new Map();
|
|
6933
|
+
const parseResult = EntryProcessorDefSchema.safeParse(processorDef);
|
|
6934
|
+
if (!parseResult.success) {
|
|
6935
|
+
return { results, timestamps };
|
|
6936
|
+
}
|
|
6937
|
+
const entries = map.entries();
|
|
6938
|
+
for (const [key, value] of entries) {
|
|
6939
|
+
if (predicateCode) {
|
|
6940
|
+
const predicateResult = await this.sandbox.execute(
|
|
6941
|
+
{
|
|
6942
|
+
name: "_predicate",
|
|
6943
|
+
code: `return { value, result: (function() { ${predicateCode} })() };`
|
|
6944
|
+
},
|
|
6945
|
+
value,
|
|
6946
|
+
key
|
|
6947
|
+
);
|
|
6948
|
+
if (!predicateResult.success || !predicateResult.result) {
|
|
6949
|
+
continue;
|
|
6950
|
+
}
|
|
6951
|
+
}
|
|
6952
|
+
const { result, timestamp } = await this.executeOnKey(
|
|
6953
|
+
map,
|
|
6954
|
+
key,
|
|
6955
|
+
processorDef
|
|
6956
|
+
);
|
|
6957
|
+
results.set(key, result);
|
|
6958
|
+
if (timestamp) {
|
|
6959
|
+
timestamps.set(key, timestamp);
|
|
6960
|
+
}
|
|
6961
|
+
}
|
|
6962
|
+
return { results, timestamps };
|
|
6963
|
+
}
|
|
6964
|
+
/**
|
|
6965
|
+
* Check if sandbox is in secure mode (using isolated-vm).
|
|
6966
|
+
*/
|
|
6967
|
+
isSecureMode() {
|
|
6968
|
+
return this.sandbox.isSecureMode();
|
|
6969
|
+
}
|
|
6970
|
+
/**
|
|
6971
|
+
* Get sandbox cache statistics.
|
|
6972
|
+
*/
|
|
6973
|
+
getCacheStats() {
|
|
6974
|
+
return this.sandbox.getCacheStats();
|
|
6975
|
+
}
|
|
6976
|
+
/**
|
|
6977
|
+
* Clear sandbox cache.
|
|
6978
|
+
*/
|
|
6979
|
+
clearCache(processorName) {
|
|
6980
|
+
this.sandbox.clearCache(processorName);
|
|
6981
|
+
}
|
|
6982
|
+
/**
|
|
6983
|
+
* Dispose of the handler and its sandbox.
|
|
6984
|
+
*/
|
|
6985
|
+
dispose() {
|
|
6986
|
+
this.sandbox.dispose();
|
|
6987
|
+
logger.debug("EntryProcessorHandler disposed");
|
|
6988
|
+
}
|
|
6989
|
+
};
|
|
6990
|
+
|
|
6991
|
+
// src/ConflictResolverService.ts
|
|
6992
|
+
import {
|
|
6993
|
+
BuiltInResolvers,
|
|
6994
|
+
ConflictResolverDefSchema,
|
|
6995
|
+
validateResolverCode
|
|
6996
|
+
} from "@topgunbuild/core";
|
|
6997
|
+
var DEFAULT_CONFLICT_RESOLVER_CONFIG = {
|
|
6998
|
+
maxResolversPerMap: 100,
|
|
6999
|
+
enableSandboxedResolvers: true,
|
|
7000
|
+
resolverTimeoutMs: 100
|
|
7001
|
+
};
|
|
7002
|
+
var ConflictResolverService = class {
|
|
7003
|
+
constructor(sandbox, config = {}) {
|
|
7004
|
+
this.resolvers = /* @__PURE__ */ new Map();
|
|
7005
|
+
this.disposed = false;
|
|
7006
|
+
this.sandbox = sandbox;
|
|
7007
|
+
this.config = { ...DEFAULT_CONFLICT_RESOLVER_CONFIG, ...config };
|
|
7008
|
+
}
|
|
7009
|
+
/**
|
|
7010
|
+
* Set callback for merge rejections.
|
|
7011
|
+
*/
|
|
7012
|
+
onRejection(callback) {
|
|
7013
|
+
this.onRejectionCallback = callback;
|
|
7014
|
+
}
|
|
7015
|
+
/**
|
|
7016
|
+
* Register a resolver for a map.
|
|
7017
|
+
*
|
|
7018
|
+
* @param mapName The map this resolver applies to
|
|
7019
|
+
* @param resolver The resolver definition
|
|
7020
|
+
* @param registeredBy Optional client ID that registered this resolver
|
|
7021
|
+
*/
|
|
7022
|
+
register(mapName, resolver, registeredBy) {
|
|
7023
|
+
if (this.disposed) {
|
|
7024
|
+
throw new Error("ConflictResolverService has been disposed");
|
|
7025
|
+
}
|
|
7026
|
+
if (resolver.code) {
|
|
7027
|
+
const parsed = ConflictResolverDefSchema.safeParse({
|
|
7028
|
+
name: resolver.name,
|
|
7029
|
+
code: resolver.code,
|
|
7030
|
+
priority: resolver.priority,
|
|
7031
|
+
keyPattern: resolver.keyPattern
|
|
7032
|
+
});
|
|
7033
|
+
if (!parsed.success) {
|
|
7034
|
+
throw new Error(`Invalid resolver definition: ${parsed.error.message}`);
|
|
7035
|
+
}
|
|
7036
|
+
const validation = validateResolverCode(resolver.code);
|
|
7037
|
+
if (!validation.valid) {
|
|
7038
|
+
throw new Error(`Invalid resolver code: ${validation.error}`);
|
|
7039
|
+
}
|
|
7040
|
+
}
|
|
7041
|
+
const entries = this.resolvers.get(mapName) ?? [];
|
|
7042
|
+
if (entries.length >= this.config.maxResolversPerMap) {
|
|
7043
|
+
throw new Error(
|
|
7044
|
+
`Maximum resolvers per map (${this.config.maxResolversPerMap}) exceeded`
|
|
7045
|
+
);
|
|
7046
|
+
}
|
|
7047
|
+
const filtered = entries.filter((e) => e.resolver.name !== resolver.name);
|
|
7048
|
+
const entry = {
|
|
7049
|
+
resolver,
|
|
7050
|
+
registeredBy
|
|
7051
|
+
};
|
|
7052
|
+
if (resolver.code && !resolver.fn && this.config.enableSandboxedResolvers) {
|
|
7053
|
+
entry.compiledFn = this.compileSandboxed(resolver.name, resolver.code);
|
|
7054
|
+
}
|
|
7055
|
+
filtered.push(entry);
|
|
7056
|
+
filtered.sort(
|
|
7057
|
+
(a, b) => (b.resolver.priority ?? 50) - (a.resolver.priority ?? 50)
|
|
7058
|
+
);
|
|
7059
|
+
this.resolvers.set(mapName, filtered);
|
|
7060
|
+
logger.debug(
|
|
7061
|
+
`Registered resolver '${resolver.name}' for map '${mapName}' with priority ${resolver.priority ?? 50}`
|
|
7062
|
+
);
|
|
7063
|
+
}
|
|
7064
|
+
/**
|
|
7065
|
+
* Unregister a resolver.
|
|
7066
|
+
*
|
|
7067
|
+
* @param mapName The map name
|
|
7068
|
+
* @param resolverName The resolver name to unregister
|
|
7069
|
+
* @param clientId Optional - only unregister if registered by this client
|
|
7070
|
+
*/
|
|
7071
|
+
unregister(mapName, resolverName, clientId) {
|
|
7072
|
+
const entries = this.resolvers.get(mapName);
|
|
7073
|
+
if (!entries) return false;
|
|
7074
|
+
const entryIndex = entries.findIndex(
|
|
7075
|
+
(e) => e.resolver.name === resolverName && (!clientId || e.registeredBy === clientId)
|
|
7076
|
+
);
|
|
7077
|
+
if (entryIndex === -1) return false;
|
|
7078
|
+
entries.splice(entryIndex, 1);
|
|
7079
|
+
if (entries.length === 0) {
|
|
7080
|
+
this.resolvers.delete(mapName);
|
|
7081
|
+
}
|
|
7082
|
+
logger.debug(`Unregistered resolver '${resolverName}' from map '${mapName}'`);
|
|
7083
|
+
return true;
|
|
7084
|
+
}
|
|
7085
|
+
/**
|
|
7086
|
+
* Resolve a merge conflict using registered resolvers.
|
|
7087
|
+
*
|
|
7088
|
+
* @param context The merge context
|
|
7089
|
+
* @returns The merge result
|
|
7090
|
+
*/
|
|
7091
|
+
async resolve(context) {
|
|
7092
|
+
if (this.disposed) {
|
|
7093
|
+
return { action: "accept", value: context.remoteValue };
|
|
7094
|
+
}
|
|
7095
|
+
const entries = this.resolvers.get(context.mapName) ?? [];
|
|
7096
|
+
const allEntries = [
|
|
7097
|
+
...entries,
|
|
7098
|
+
{ resolver: BuiltInResolvers.LWW() }
|
|
7099
|
+
];
|
|
7100
|
+
for (const entry of allEntries) {
|
|
7101
|
+
const { resolver } = entry;
|
|
7102
|
+
if (resolver.keyPattern && !this.matchKeyPattern(context.key, resolver.keyPattern)) {
|
|
7103
|
+
continue;
|
|
7104
|
+
}
|
|
7105
|
+
try {
|
|
7106
|
+
let result;
|
|
7107
|
+
if (resolver.fn) {
|
|
7108
|
+
const fn = resolver.fn;
|
|
7109
|
+
const maybePromise = fn(context);
|
|
7110
|
+
result = maybePromise instanceof Promise ? await maybePromise : maybePromise;
|
|
7111
|
+
} else if (entry.compiledFn) {
|
|
7112
|
+
const compiledFn = entry.compiledFn;
|
|
7113
|
+
result = await compiledFn(context);
|
|
7114
|
+
} else {
|
|
7115
|
+
continue;
|
|
7116
|
+
}
|
|
7117
|
+
if (result.action !== "local") {
|
|
7118
|
+
if (result.action === "reject") {
|
|
7119
|
+
logger.debug(
|
|
7120
|
+
`Resolver '${resolver.name}' rejected merge for key '${context.key}' in map '${context.mapName}': ${result.reason}`
|
|
7121
|
+
);
|
|
7122
|
+
if (this.onRejectionCallback) {
|
|
7123
|
+
this.onRejectionCallback({
|
|
7124
|
+
mapName: context.mapName,
|
|
7125
|
+
key: context.key,
|
|
7126
|
+
attemptedValue: context.remoteValue,
|
|
7127
|
+
reason: result.reason,
|
|
7128
|
+
timestamp: context.remoteTimestamp,
|
|
7129
|
+
nodeId: context.remoteNodeId
|
|
7130
|
+
});
|
|
7131
|
+
}
|
|
7132
|
+
}
|
|
7133
|
+
return result;
|
|
7134
|
+
}
|
|
7135
|
+
} catch (error) {
|
|
7136
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
7137
|
+
logger.error(`Resolver '${resolver.name}' threw error: ${message}`);
|
|
7138
|
+
}
|
|
7139
|
+
}
|
|
7140
|
+
return { action: "accept", value: context.remoteValue };
|
|
7141
|
+
}
|
|
7142
|
+
/**
|
|
7143
|
+
* List registered resolvers.
|
|
7144
|
+
*
|
|
7145
|
+
* @param mapName Optional - filter by map name
|
|
7146
|
+
*/
|
|
7147
|
+
list(mapName) {
|
|
7148
|
+
const result = [];
|
|
7149
|
+
if (mapName) {
|
|
7150
|
+
const entries = this.resolvers.get(mapName) ?? [];
|
|
7151
|
+
for (const entry of entries) {
|
|
7152
|
+
result.push({
|
|
7153
|
+
mapName,
|
|
7154
|
+
name: entry.resolver.name,
|
|
7155
|
+
priority: entry.resolver.priority,
|
|
7156
|
+
keyPattern: entry.resolver.keyPattern,
|
|
7157
|
+
registeredBy: entry.registeredBy
|
|
7158
|
+
});
|
|
7159
|
+
}
|
|
7160
|
+
} else {
|
|
7161
|
+
for (const [map, entries] of this.resolvers.entries()) {
|
|
7162
|
+
for (const entry of entries) {
|
|
7163
|
+
result.push({
|
|
7164
|
+
mapName: map,
|
|
7165
|
+
name: entry.resolver.name,
|
|
7166
|
+
priority: entry.resolver.priority,
|
|
7167
|
+
keyPattern: entry.resolver.keyPattern,
|
|
7168
|
+
registeredBy: entry.registeredBy
|
|
7169
|
+
});
|
|
7170
|
+
}
|
|
7171
|
+
}
|
|
7172
|
+
}
|
|
7173
|
+
return result;
|
|
7174
|
+
}
|
|
7175
|
+
/**
|
|
7176
|
+
* Check if a map has any registered resolvers.
|
|
7177
|
+
*/
|
|
7178
|
+
hasResolvers(mapName) {
|
|
7179
|
+
const entries = this.resolvers.get(mapName);
|
|
7180
|
+
return entries !== void 0 && entries.length > 0;
|
|
7181
|
+
}
|
|
7182
|
+
/**
|
|
7183
|
+
* Get the number of registered resolvers.
|
|
7184
|
+
*/
|
|
7185
|
+
get size() {
|
|
7186
|
+
let count = 0;
|
|
7187
|
+
for (const entries of this.resolvers.values()) {
|
|
7188
|
+
count += entries.length;
|
|
7189
|
+
}
|
|
7190
|
+
return count;
|
|
7191
|
+
}
|
|
7192
|
+
/**
|
|
7193
|
+
* Clear all registered resolvers.
|
|
7194
|
+
*
|
|
7195
|
+
* @param mapName Optional - only clear resolvers for specific map
|
|
7196
|
+
*/
|
|
7197
|
+
clear(mapName) {
|
|
7198
|
+
if (mapName) {
|
|
7199
|
+
this.resolvers.delete(mapName);
|
|
7200
|
+
} else {
|
|
7201
|
+
this.resolvers.clear();
|
|
7202
|
+
}
|
|
7203
|
+
}
|
|
7204
|
+
/**
|
|
7205
|
+
* Clear resolvers registered by a specific client.
|
|
7206
|
+
*/
|
|
7207
|
+
clearByClient(clientId) {
|
|
7208
|
+
let removed = 0;
|
|
7209
|
+
for (const [mapName, entries] of this.resolvers.entries()) {
|
|
7210
|
+
const before = entries.length;
|
|
7211
|
+
const filtered = entries.filter((e) => e.registeredBy !== clientId);
|
|
7212
|
+
removed += before - filtered.length;
|
|
7213
|
+
if (filtered.length === 0) {
|
|
7214
|
+
this.resolvers.delete(mapName);
|
|
7215
|
+
} else if (filtered.length !== before) {
|
|
7216
|
+
this.resolvers.set(mapName, filtered);
|
|
7217
|
+
}
|
|
7218
|
+
}
|
|
7219
|
+
return removed;
|
|
7220
|
+
}
|
|
7221
|
+
/**
|
|
7222
|
+
* Dispose the service.
|
|
7223
|
+
*/
|
|
7224
|
+
dispose() {
|
|
7225
|
+
if (this.disposed) return;
|
|
7226
|
+
this.disposed = true;
|
|
7227
|
+
this.resolvers.clear();
|
|
7228
|
+
logger.debug("ConflictResolverService disposed");
|
|
7229
|
+
}
|
|
7230
|
+
/**
|
|
7231
|
+
* Match a key against a glob-like pattern.
|
|
7232
|
+
* Supports * (any chars) and ? (single char).
|
|
7233
|
+
*/
|
|
7234
|
+
matchKeyPattern(key, pattern) {
|
|
7235
|
+
const regexPattern = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
7236
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
7237
|
+
return regex.test(key);
|
|
7238
|
+
}
|
|
7239
|
+
/**
|
|
7240
|
+
* Compile sandboxed resolver code.
|
|
7241
|
+
*/
|
|
7242
|
+
compileSandboxed(name, code) {
|
|
7243
|
+
return async (ctx) => {
|
|
7244
|
+
const wrappedCode = `
|
|
7245
|
+
const context = {
|
|
7246
|
+
mapName: ${JSON.stringify(ctx.mapName)},
|
|
7247
|
+
key: ${JSON.stringify(ctx.key)},
|
|
7248
|
+
localValue: ${JSON.stringify(ctx.localValue)},
|
|
7249
|
+
remoteValue: ${JSON.stringify(ctx.remoteValue)},
|
|
7250
|
+
localTimestamp: ${JSON.stringify(ctx.localTimestamp)},
|
|
7251
|
+
remoteTimestamp: ${JSON.stringify(ctx.remoteTimestamp)},
|
|
7252
|
+
remoteNodeId: ${JSON.stringify(ctx.remoteNodeId)},
|
|
7253
|
+
auth: ${JSON.stringify(ctx.auth)},
|
|
7254
|
+
};
|
|
7255
|
+
|
|
7256
|
+
function resolve(context) {
|
|
7257
|
+
${code}
|
|
7258
|
+
}
|
|
7259
|
+
|
|
7260
|
+
const result = resolve(context);
|
|
7261
|
+
return { value: result, result };
|
|
7262
|
+
`;
|
|
7263
|
+
const result = await this.sandbox.execute(
|
|
7264
|
+
{
|
|
7265
|
+
name: `resolver:${name}`,
|
|
7266
|
+
code: wrappedCode
|
|
7267
|
+
},
|
|
7268
|
+
null,
|
|
7269
|
+
// value parameter unused for resolvers
|
|
7270
|
+
"resolver"
|
|
7271
|
+
);
|
|
7272
|
+
if (!result.success) {
|
|
7273
|
+
throw new Error(result.error || "Resolver execution failed");
|
|
7274
|
+
}
|
|
7275
|
+
const resolverResult = result.result;
|
|
7276
|
+
if (!resolverResult || typeof resolverResult !== "object") {
|
|
7277
|
+
throw new Error("Resolver must return a result object");
|
|
7278
|
+
}
|
|
7279
|
+
const action = resolverResult.action;
|
|
7280
|
+
if (!["accept", "reject", "merge", "local"].includes(action)) {
|
|
7281
|
+
throw new Error(`Invalid resolver action: ${action}`);
|
|
7282
|
+
}
|
|
7283
|
+
return resolverResult;
|
|
7284
|
+
};
|
|
7285
|
+
}
|
|
7286
|
+
};
|
|
7287
|
+
|
|
7288
|
+
// src/handlers/ConflictResolverHandler.ts
|
|
7289
|
+
var ConflictResolverHandler = class {
|
|
7290
|
+
constructor(config) {
|
|
7291
|
+
this.rejectionListeners = /* @__PURE__ */ new Set();
|
|
7292
|
+
this.nodeId = config.nodeId;
|
|
7293
|
+
this.sandbox = new ProcessorSandbox(config.sandboxConfig);
|
|
7294
|
+
this.resolverService = new ConflictResolverService(
|
|
7295
|
+
this.sandbox,
|
|
7296
|
+
config.resolverConfig
|
|
7297
|
+
);
|
|
7298
|
+
this.resolverService.onRejection((rejection) => {
|
|
7299
|
+
for (const listener of this.rejectionListeners) {
|
|
7300
|
+
try {
|
|
7301
|
+
listener(rejection);
|
|
7302
|
+
} catch (e) {
|
|
7303
|
+
logger.error({ error: e }, "Error in rejection listener");
|
|
7304
|
+
}
|
|
7305
|
+
}
|
|
7306
|
+
});
|
|
7307
|
+
}
|
|
7308
|
+
/**
|
|
7309
|
+
* Register a conflict resolver for a map.
|
|
7310
|
+
*
|
|
7311
|
+
* @param mapName The map name
|
|
7312
|
+
* @param resolver The resolver definition
|
|
7313
|
+
* @param clientId Optional client ID that registered this resolver
|
|
7314
|
+
*/
|
|
7315
|
+
registerResolver(mapName, resolver, clientId) {
|
|
7316
|
+
this.resolverService.register(mapName, resolver, clientId);
|
|
7317
|
+
logger.info(
|
|
7318
|
+
{
|
|
7319
|
+
mapName,
|
|
7320
|
+
resolverName: resolver.name,
|
|
7321
|
+
priority: resolver.priority,
|
|
7322
|
+
clientId
|
|
7323
|
+
},
|
|
7324
|
+
"Resolver registered"
|
|
7325
|
+
);
|
|
7326
|
+
}
|
|
7327
|
+
/**
|
|
7328
|
+
* Unregister a conflict resolver.
|
|
7329
|
+
*
|
|
7330
|
+
* @param mapName The map name
|
|
7331
|
+
* @param resolverName The resolver name
|
|
7332
|
+
* @param clientId Optional - only unregister if registered by this client
|
|
7333
|
+
*/
|
|
7334
|
+
unregisterResolver(mapName, resolverName, clientId) {
|
|
7335
|
+
const removed = this.resolverService.unregister(
|
|
7336
|
+
mapName,
|
|
7337
|
+
resolverName,
|
|
7338
|
+
clientId
|
|
7339
|
+
);
|
|
7340
|
+
if (removed) {
|
|
7341
|
+
logger.info({ mapName, resolverName, clientId }, "Resolver unregistered");
|
|
7342
|
+
}
|
|
7343
|
+
return removed;
|
|
7344
|
+
}
|
|
7345
|
+
/**
|
|
7346
|
+
* List registered resolvers.
|
|
7347
|
+
*
|
|
7348
|
+
* @param mapName Optional - filter by map name
|
|
7349
|
+
*/
|
|
7350
|
+
listResolvers(mapName) {
|
|
7351
|
+
return this.resolverService.list(mapName);
|
|
7352
|
+
}
|
|
7353
|
+
/**
|
|
7354
|
+
* Apply a merge with conflict resolution.
|
|
7355
|
+
*
|
|
7356
|
+
* Deletions (tombstones) are also passed through resolvers to allow
|
|
7357
|
+
* protection via IMMUTABLE, OWNER_ONLY, or similar resolvers.
|
|
7358
|
+
* If no custom resolvers are registered, deletions use standard LWW.
|
|
7359
|
+
*
|
|
7360
|
+
* @param map The LWWMap to merge into
|
|
7361
|
+
* @param mapName The map name (for resolver lookup)
|
|
7362
|
+
* @param key The key being merged
|
|
7363
|
+
* @param record The incoming record
|
|
7364
|
+
* @param remoteNodeId The source node ID
|
|
7365
|
+
* @param auth Optional authentication context
|
|
7366
|
+
*/
|
|
7367
|
+
async mergeWithResolver(map, mapName, key, record, remoteNodeId, auth) {
|
|
7368
|
+
const isDeletion = record.value === null;
|
|
7369
|
+
const localRecord = map.getRecord(key);
|
|
7370
|
+
const context = {
|
|
7371
|
+
mapName,
|
|
7372
|
+
key,
|
|
7373
|
+
localValue: localRecord?.value ?? void 0,
|
|
7374
|
+
// For deletions, remoteValue is null - resolvers can check this
|
|
7375
|
+
remoteValue: record.value,
|
|
7376
|
+
localTimestamp: localRecord?.timestamp,
|
|
7377
|
+
remoteTimestamp: record.timestamp,
|
|
7378
|
+
remoteNodeId,
|
|
7379
|
+
auth,
|
|
7380
|
+
readEntry: (k) => map.get(k)
|
|
7381
|
+
};
|
|
7382
|
+
const result = await this.resolverService.resolve(context);
|
|
7383
|
+
switch (result.action) {
|
|
7384
|
+
case "accept":
|
|
7385
|
+
case "merge": {
|
|
7386
|
+
const finalValue = isDeletion ? null : result.value;
|
|
7387
|
+
const finalRecord = {
|
|
7388
|
+
value: finalValue,
|
|
7389
|
+
timestamp: record.timestamp,
|
|
7390
|
+
ttlMs: record.ttlMs
|
|
7391
|
+
};
|
|
7392
|
+
map.merge(key, finalRecord);
|
|
7393
|
+
return { applied: true, result, record: finalRecord };
|
|
7394
|
+
}
|
|
7395
|
+
case "reject": {
|
|
7396
|
+
const rejection = {
|
|
7397
|
+
mapName,
|
|
7398
|
+
key,
|
|
7399
|
+
attemptedValue: record.value,
|
|
7400
|
+
reason: result.reason,
|
|
7401
|
+
timestamp: record.timestamp,
|
|
7402
|
+
nodeId: remoteNodeId
|
|
7403
|
+
};
|
|
7404
|
+
return { applied: false, result, rejection };
|
|
7405
|
+
}
|
|
7406
|
+
case "local":
|
|
7407
|
+
default:
|
|
7408
|
+
return { applied: false, result };
|
|
7409
|
+
}
|
|
7410
|
+
}
|
|
7411
|
+
/**
|
|
7412
|
+
* Check if a map has custom resolvers registered.
|
|
7413
|
+
*/
|
|
7414
|
+
hasResolvers(mapName) {
|
|
7415
|
+
return this.resolverService.hasResolvers(mapName);
|
|
7416
|
+
}
|
|
7417
|
+
/**
|
|
7418
|
+
* Add a listener for merge rejections.
|
|
7419
|
+
*/
|
|
7420
|
+
onRejection(listener) {
|
|
7421
|
+
this.rejectionListeners.add(listener);
|
|
7422
|
+
return () => this.rejectionListeners.delete(listener);
|
|
7423
|
+
}
|
|
7424
|
+
/**
|
|
7425
|
+
* Clear resolvers registered by a specific client.
|
|
7426
|
+
*/
|
|
7427
|
+
clearByClient(clientId) {
|
|
7428
|
+
return this.resolverService.clearByClient(clientId);
|
|
7429
|
+
}
|
|
7430
|
+
/**
|
|
7431
|
+
* Get the number of registered resolvers.
|
|
7432
|
+
*/
|
|
7433
|
+
get resolverCount() {
|
|
7434
|
+
return this.resolverService.size;
|
|
7435
|
+
}
|
|
7436
|
+
/**
|
|
7437
|
+
* Check if sandbox is in secure mode.
|
|
7438
|
+
*/
|
|
7439
|
+
isSecureMode() {
|
|
7440
|
+
return this.sandbox.isSecureMode();
|
|
7441
|
+
}
|
|
7442
|
+
/**
|
|
7443
|
+
* Dispose of the handler.
|
|
7444
|
+
*/
|
|
7445
|
+
dispose() {
|
|
7446
|
+
this.resolverService.dispose();
|
|
7447
|
+
this.sandbox.dispose();
|
|
7448
|
+
this.rejectionListeners.clear();
|
|
7449
|
+
logger.debug("ConflictResolverHandler disposed");
|
|
7450
|
+
}
|
|
7451
|
+
};
|
|
7452
|
+
|
|
7453
|
+
// src/EventJournalService.ts
|
|
7454
|
+
import {
|
|
7455
|
+
EventJournalImpl,
|
|
7456
|
+
DEFAULT_EVENT_JOURNAL_CONFIG
|
|
7457
|
+
} from "@topgunbuild/core";
|
|
7458
|
+
var DEFAULT_JOURNAL_SERVICE_CONFIG = {
|
|
7459
|
+
...DEFAULT_EVENT_JOURNAL_CONFIG,
|
|
7460
|
+
tableName: "event_journal",
|
|
7461
|
+
persistBatchSize: 100,
|
|
7462
|
+
persistIntervalMs: 1e3
|
|
7463
|
+
};
|
|
7464
|
+
var TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
7465
|
+
function validateTableName(name) {
|
|
7466
|
+
if (!TABLE_NAME_REGEX.test(name)) {
|
|
7467
|
+
throw new Error(
|
|
7468
|
+
`Invalid table name "${name}". Table name must start with a letter or underscore and contain only alphanumeric characters and underscores.`
|
|
7469
|
+
);
|
|
7470
|
+
}
|
|
7471
|
+
}
|
|
7472
|
+
var EventJournalService = class extends EventJournalImpl {
|
|
7473
|
+
constructor(config) {
|
|
7474
|
+
super(config);
|
|
7475
|
+
this.pendingPersist = [];
|
|
7476
|
+
this.isPersisting = false;
|
|
7477
|
+
this.isInitialized = false;
|
|
7478
|
+
this.isLoadingFromStorage = false;
|
|
7479
|
+
this.pool = config.pool;
|
|
7480
|
+
this.tableName = config.tableName ?? DEFAULT_JOURNAL_SERVICE_CONFIG.tableName;
|
|
7481
|
+
this.persistBatchSize = config.persistBatchSize ?? DEFAULT_JOURNAL_SERVICE_CONFIG.persistBatchSize;
|
|
7482
|
+
this.persistIntervalMs = config.persistIntervalMs ?? DEFAULT_JOURNAL_SERVICE_CONFIG.persistIntervalMs;
|
|
7483
|
+
validateTableName(this.tableName);
|
|
7484
|
+
this.subscribe((event) => {
|
|
7485
|
+
if (this.isLoadingFromStorage) return;
|
|
7486
|
+
if (event.sequence >= 0n && this.getConfig().persistent) {
|
|
7487
|
+
this.pendingPersist.push(event);
|
|
7488
|
+
if (this.pendingPersist.length >= this.persistBatchSize) {
|
|
7489
|
+
this.persistToStorage().catch((err) => {
|
|
7490
|
+
logger.error({ err }, "Failed to persist journal events");
|
|
7491
|
+
});
|
|
7492
|
+
}
|
|
7493
|
+
}
|
|
7494
|
+
});
|
|
7495
|
+
this.startPersistTimer();
|
|
7496
|
+
}
|
|
7497
|
+
/**
|
|
7498
|
+
* Initialize the journal service, creating table if needed.
|
|
7499
|
+
*/
|
|
7500
|
+
async initialize() {
|
|
7501
|
+
if (this.isInitialized) return;
|
|
7502
|
+
const client = await this.pool.connect();
|
|
7503
|
+
try {
|
|
7504
|
+
await client.query(`
|
|
7505
|
+
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
7506
|
+
sequence BIGINT PRIMARY KEY,
|
|
7507
|
+
type VARCHAR(10) NOT NULL CHECK (type IN ('PUT', 'UPDATE', 'DELETE')),
|
|
7508
|
+
map_name VARCHAR(255) NOT NULL,
|
|
7509
|
+
key VARCHAR(1024) NOT NULL,
|
|
7510
|
+
value JSONB,
|
|
7511
|
+
previous_value JSONB,
|
|
7512
|
+
timestamp JSONB NOT NULL,
|
|
7513
|
+
node_id VARCHAR(64) NOT NULL,
|
|
7514
|
+
metadata JSONB,
|
|
7515
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
7516
|
+
);
|
|
7517
|
+
`);
|
|
7518
|
+
await client.query(`
|
|
7519
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_map_name
|
|
7520
|
+
ON ${this.tableName}(map_name);
|
|
7521
|
+
`);
|
|
7522
|
+
await client.query(`
|
|
7523
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_key
|
|
7524
|
+
ON ${this.tableName}(map_name, key);
|
|
7525
|
+
`);
|
|
7526
|
+
await client.query(`
|
|
7527
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_created_at
|
|
7528
|
+
ON ${this.tableName}(created_at);
|
|
7529
|
+
`);
|
|
7530
|
+
await client.query(`
|
|
7531
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_node_id
|
|
7532
|
+
ON ${this.tableName}(node_id);
|
|
7533
|
+
`);
|
|
7534
|
+
this.isInitialized = true;
|
|
7535
|
+
logger.info({ tableName: this.tableName }, "EventJournalService initialized");
|
|
7536
|
+
} finally {
|
|
7537
|
+
client.release();
|
|
7538
|
+
}
|
|
7539
|
+
}
|
|
7540
|
+
/**
|
|
7541
|
+
* Persist pending events to PostgreSQL.
|
|
7542
|
+
*/
|
|
7543
|
+
async persistToStorage() {
|
|
7544
|
+
if (this.pendingPersist.length === 0 || this.isPersisting) return;
|
|
7545
|
+
this.isPersisting = true;
|
|
7546
|
+
const batch = this.pendingPersist.splice(0, this.persistBatchSize);
|
|
7547
|
+
try {
|
|
7548
|
+
if (batch.length === 0) return;
|
|
7549
|
+
const values = [];
|
|
7550
|
+
const placeholders = [];
|
|
7551
|
+
batch.forEach((e, i) => {
|
|
7552
|
+
const offset = i * 9;
|
|
7553
|
+
placeholders.push(
|
|
7554
|
+
`($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6}, $${offset + 7}, $${offset + 8}, $${offset + 9})`
|
|
7555
|
+
);
|
|
7556
|
+
values.push(
|
|
7557
|
+
e.sequence.toString(),
|
|
7558
|
+
e.type,
|
|
7559
|
+
e.mapName,
|
|
7560
|
+
e.key,
|
|
7561
|
+
e.value !== void 0 ? JSON.stringify(e.value) : null,
|
|
7562
|
+
e.previousValue !== void 0 ? JSON.stringify(e.previousValue) : null,
|
|
7563
|
+
JSON.stringify(e.timestamp),
|
|
7564
|
+
e.nodeId,
|
|
7565
|
+
e.metadata ? JSON.stringify(e.metadata) : null
|
|
7566
|
+
);
|
|
7567
|
+
});
|
|
7568
|
+
await this.pool.query(
|
|
7569
|
+
`INSERT INTO ${this.tableName}
|
|
7570
|
+
(sequence, type, map_name, key, value, previous_value, timestamp, node_id, metadata)
|
|
7571
|
+
VALUES ${placeholders.join(", ")}
|
|
7572
|
+
ON CONFLICT (sequence) DO NOTHING`,
|
|
7573
|
+
values
|
|
7574
|
+
);
|
|
7575
|
+
logger.debug({ count: batch.length }, "Persisted journal events");
|
|
7576
|
+
} catch (error) {
|
|
7577
|
+
this.pendingPersist.unshift(...batch);
|
|
7578
|
+
throw error;
|
|
7579
|
+
} finally {
|
|
7580
|
+
this.isPersisting = false;
|
|
7581
|
+
}
|
|
7582
|
+
}
|
|
7583
|
+
/**
|
|
7584
|
+
* Load journal events from PostgreSQL on startup.
|
|
7585
|
+
*/
|
|
7586
|
+
async loadFromStorage() {
|
|
7587
|
+
const config = this.getConfig();
|
|
7588
|
+
const result = await this.pool.query(
|
|
7589
|
+
`SELECT sequence, type, map_name, key, value, previous_value, timestamp, node_id, metadata
|
|
7590
|
+
FROM ${this.tableName}
|
|
7591
|
+
ORDER BY sequence DESC
|
|
7592
|
+
LIMIT $1`,
|
|
7593
|
+
[config.capacity]
|
|
7594
|
+
);
|
|
7595
|
+
const events = result.rows.reverse();
|
|
7596
|
+
this.isLoadingFromStorage = true;
|
|
7597
|
+
try {
|
|
7598
|
+
for (const row of events) {
|
|
7599
|
+
this.append({
|
|
7600
|
+
type: row.type,
|
|
7601
|
+
mapName: row.map_name,
|
|
7602
|
+
key: row.key,
|
|
7603
|
+
value: row.value,
|
|
7604
|
+
previousValue: row.previous_value,
|
|
7605
|
+
timestamp: typeof row.timestamp === "string" ? JSON.parse(row.timestamp) : row.timestamp,
|
|
7606
|
+
nodeId: row.node_id,
|
|
7607
|
+
metadata: row.metadata
|
|
7608
|
+
});
|
|
7609
|
+
}
|
|
7610
|
+
} finally {
|
|
7611
|
+
this.isLoadingFromStorage = false;
|
|
7612
|
+
}
|
|
7613
|
+
logger.info({ count: events.length }, "Loaded journal events from storage");
|
|
7614
|
+
}
|
|
7615
|
+
/**
|
|
7616
|
+
* Export events as NDJSON stream.
|
|
7617
|
+
*/
|
|
7618
|
+
exportStream(options = {}) {
|
|
7619
|
+
const self = this;
|
|
7620
|
+
return new ReadableStream({
|
|
7621
|
+
start(controller) {
|
|
7622
|
+
const startSeq = options.fromSequence ?? self.getOldestSequence();
|
|
7623
|
+
const endSeq = options.toSequence ?? self.getLatestSequence();
|
|
7624
|
+
for (let seq = startSeq; seq <= endSeq; seq++) {
|
|
7625
|
+
const events = self.readFrom(seq, 1);
|
|
7626
|
+
if (events.length > 0) {
|
|
7627
|
+
const event = events[0];
|
|
7628
|
+
if (options.mapName && event.mapName !== options.mapName) continue;
|
|
7629
|
+
if (options.types && !options.types.includes(event.type)) continue;
|
|
7630
|
+
const serializable = {
|
|
7631
|
+
...event,
|
|
7632
|
+
sequence: event.sequence.toString()
|
|
7633
|
+
};
|
|
7634
|
+
controller.enqueue(JSON.stringify(serializable) + "\n");
|
|
7635
|
+
}
|
|
7636
|
+
}
|
|
7637
|
+
controller.close();
|
|
7638
|
+
}
|
|
7639
|
+
});
|
|
7640
|
+
}
|
|
7641
|
+
/**
|
|
7642
|
+
* Get events for a specific map.
|
|
7643
|
+
*/
|
|
7644
|
+
getMapEvents(mapName, fromSeq) {
|
|
7645
|
+
const events = this.readFrom(fromSeq ?? this.getOldestSequence(), this.getConfig().capacity);
|
|
7646
|
+
return events.filter((e) => e.mapName === mapName);
|
|
7647
|
+
}
|
|
7648
|
+
/**
|
|
7649
|
+
* Query events from PostgreSQL with filters.
|
|
7650
|
+
*/
|
|
7651
|
+
async queryFromStorage(options = {}) {
|
|
7652
|
+
const conditions = [];
|
|
7653
|
+
const params = [];
|
|
7654
|
+
let paramIndex = 1;
|
|
7655
|
+
if (options.mapName) {
|
|
7656
|
+
conditions.push(`map_name = $${paramIndex++}`);
|
|
7657
|
+
params.push(options.mapName);
|
|
7658
|
+
}
|
|
7659
|
+
if (options.key) {
|
|
7660
|
+
conditions.push(`key = $${paramIndex++}`);
|
|
7661
|
+
params.push(options.key);
|
|
7662
|
+
}
|
|
7663
|
+
if (options.types && options.types.length > 0) {
|
|
7664
|
+
conditions.push(`type = ANY($${paramIndex++})`);
|
|
7665
|
+
params.push(options.types);
|
|
7666
|
+
}
|
|
7667
|
+
if (options.fromSequence !== void 0) {
|
|
7668
|
+
conditions.push(`sequence >= $${paramIndex++}`);
|
|
7669
|
+
params.push(options.fromSequence.toString());
|
|
7670
|
+
}
|
|
7671
|
+
if (options.toSequence !== void 0) {
|
|
7672
|
+
conditions.push(`sequence <= $${paramIndex++}`);
|
|
7673
|
+
params.push(options.toSequence.toString());
|
|
7674
|
+
}
|
|
7675
|
+
if (options.fromDate) {
|
|
7676
|
+
conditions.push(`created_at >= $${paramIndex++}`);
|
|
7677
|
+
params.push(options.fromDate);
|
|
7678
|
+
}
|
|
7679
|
+
if (options.toDate) {
|
|
7680
|
+
conditions.push(`created_at <= $${paramIndex++}`);
|
|
7681
|
+
params.push(options.toDate);
|
|
7682
|
+
}
|
|
7683
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
7684
|
+
const limit = options.limit ?? 100;
|
|
7685
|
+
const offset = options.offset ?? 0;
|
|
7686
|
+
const result = await this.pool.query(
|
|
7687
|
+
`SELECT sequence, type, map_name, key, value, previous_value, timestamp, node_id, metadata
|
|
7688
|
+
FROM ${this.tableName}
|
|
7689
|
+
${whereClause}
|
|
7690
|
+
ORDER BY sequence ASC
|
|
7691
|
+
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
|
7692
|
+
[...params, limit, offset]
|
|
7693
|
+
);
|
|
7694
|
+
return result.rows.map((row) => ({
|
|
7695
|
+
sequence: BigInt(row.sequence),
|
|
7696
|
+
type: row.type,
|
|
7697
|
+
mapName: row.map_name,
|
|
7698
|
+
key: row.key,
|
|
7699
|
+
value: row.value,
|
|
7700
|
+
previousValue: row.previous_value,
|
|
7701
|
+
timestamp: typeof row.timestamp === "string" ? JSON.parse(row.timestamp) : row.timestamp,
|
|
7702
|
+
nodeId: row.node_id,
|
|
7703
|
+
metadata: row.metadata
|
|
7704
|
+
}));
|
|
7705
|
+
}
|
|
7706
|
+
/**
|
|
7707
|
+
* Count events matching filters.
|
|
7708
|
+
*/
|
|
7709
|
+
async countFromStorage(options = {}) {
|
|
7710
|
+
const conditions = [];
|
|
7711
|
+
const params = [];
|
|
7712
|
+
let paramIndex = 1;
|
|
7713
|
+
if (options.mapName) {
|
|
7714
|
+
conditions.push(`map_name = $${paramIndex++}`);
|
|
7715
|
+
params.push(options.mapName);
|
|
7716
|
+
}
|
|
7717
|
+
if (options.types && options.types.length > 0) {
|
|
7718
|
+
conditions.push(`type = ANY($${paramIndex++})`);
|
|
7719
|
+
params.push(options.types);
|
|
7720
|
+
}
|
|
7721
|
+
if (options.fromDate) {
|
|
7722
|
+
conditions.push(`created_at >= $${paramIndex++}`);
|
|
7723
|
+
params.push(options.fromDate);
|
|
7724
|
+
}
|
|
7725
|
+
if (options.toDate) {
|
|
7726
|
+
conditions.push(`created_at <= $${paramIndex++}`);
|
|
7727
|
+
params.push(options.toDate);
|
|
7728
|
+
}
|
|
7729
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
7730
|
+
const result = await this.pool.query(
|
|
7731
|
+
`SELECT COUNT(*) as count FROM ${this.tableName} ${whereClause}`,
|
|
7732
|
+
params
|
|
7733
|
+
);
|
|
7734
|
+
return parseInt(result.rows[0].count, 10);
|
|
7735
|
+
}
|
|
7736
|
+
/**
|
|
7737
|
+
* Cleanup old events based on retention policy.
|
|
7738
|
+
*/
|
|
7739
|
+
async cleanupOldEvents(retentionDays) {
|
|
7740
|
+
const result = await this.pool.query(
|
|
7741
|
+
`DELETE FROM ${this.tableName}
|
|
7742
|
+
WHERE created_at < NOW() - ($1 || ' days')::INTERVAL
|
|
7743
|
+
RETURNING sequence`,
|
|
7744
|
+
[retentionDays]
|
|
7745
|
+
);
|
|
7746
|
+
const count = result.rowCount ?? 0;
|
|
7747
|
+
if (count > 0) {
|
|
7748
|
+
logger.info({ deletedCount: count, retentionDays }, "Cleaned up old journal events");
|
|
7749
|
+
}
|
|
7750
|
+
return count;
|
|
7751
|
+
}
|
|
7752
|
+
/**
|
|
7753
|
+
* Start the periodic persistence timer.
|
|
7754
|
+
*/
|
|
7755
|
+
startPersistTimer() {
|
|
7756
|
+
this.persistTimer = setInterval(() => {
|
|
7757
|
+
if (this.pendingPersist.length > 0) {
|
|
7758
|
+
this.persistToStorage().catch((err) => {
|
|
7759
|
+
logger.error({ err }, "Periodic persist failed");
|
|
7760
|
+
});
|
|
7761
|
+
}
|
|
7762
|
+
}, this.persistIntervalMs);
|
|
7763
|
+
}
|
|
7764
|
+
/**
|
|
7765
|
+
* Stop the periodic persistence timer.
|
|
7766
|
+
*/
|
|
7767
|
+
stopPersistTimer() {
|
|
7768
|
+
if (this.persistTimer) {
|
|
7769
|
+
clearInterval(this.persistTimer);
|
|
7770
|
+
this.persistTimer = void 0;
|
|
7771
|
+
}
|
|
7772
|
+
}
|
|
7773
|
+
/**
|
|
7774
|
+
* Dispose resources and persist remaining events.
|
|
7775
|
+
*/
|
|
7776
|
+
dispose() {
|
|
7777
|
+
this.stopPersistTimer();
|
|
7778
|
+
if (this.pendingPersist.length > 0) {
|
|
7779
|
+
this.persistToStorage().catch((err) => {
|
|
7780
|
+
logger.error({ err }, "Final persist failed on dispose");
|
|
7781
|
+
});
|
|
7782
|
+
}
|
|
7783
|
+
super.dispose();
|
|
7784
|
+
}
|
|
7785
|
+
/**
|
|
7786
|
+
* Get pending persist count (for monitoring).
|
|
7787
|
+
*/
|
|
7788
|
+
getPendingPersistCount() {
|
|
7789
|
+
return this.pendingPersist.length;
|
|
7790
|
+
}
|
|
7791
|
+
};
|
|
7792
|
+
|
|
6415
7793
|
// src/ServerCoordinator.ts
|
|
6416
7794
|
var GC_INTERVAL_MS = 60 * 60 * 1e3;
|
|
6417
7795
|
var GC_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
@@ -6431,12 +7809,14 @@ var ServerCoordinator = class {
|
|
|
6431
7809
|
this.mapLoadingPromises = /* @__PURE__ */ new Map();
|
|
6432
7810
|
// Track pending batch operations for testing purposes
|
|
6433
7811
|
this.pendingBatchOperations = /* @__PURE__ */ new Set();
|
|
7812
|
+
this.journalSubscriptions = /* @__PURE__ */ new Map();
|
|
6434
7813
|
this._actualPort = 0;
|
|
6435
7814
|
this._actualClusterPort = 0;
|
|
6436
7815
|
this._readyPromise = new Promise((resolve) => {
|
|
6437
7816
|
this._readyResolve = resolve;
|
|
6438
7817
|
});
|
|
6439
|
-
this.
|
|
7818
|
+
this._nodeId = config.nodeId;
|
|
7819
|
+
this.hlc = new HLC2(config.nodeId);
|
|
6440
7820
|
this.storage = config.storage;
|
|
6441
7821
|
const rawSecret = config.jwtSecret || process.env.JWT_SECRET || "topgun-secret-dev";
|
|
6442
7822
|
this.jwtSecret = rawSecret.replace(/\\n/g, "\n");
|
|
@@ -6600,6 +7980,27 @@ var ServerCoordinator = class {
|
|
|
6600
7980
|
}
|
|
6601
7981
|
}
|
|
6602
7982
|
});
|
|
7983
|
+
this.counterHandler = new CounterHandler(this._nodeId);
|
|
7984
|
+
this.entryProcessorHandler = new EntryProcessorHandler({ hlc: this.hlc });
|
|
7985
|
+
this.conflictResolverHandler = new ConflictResolverHandler({ nodeId: this._nodeId });
|
|
7986
|
+
this.conflictResolverHandler.onRejection((rejection) => {
|
|
7987
|
+
this.notifyMergeRejection(rejection);
|
|
7988
|
+
});
|
|
7989
|
+
if (config.eventJournalEnabled && this.storage && "pool" in this.storage) {
|
|
7990
|
+
const pool = this.storage.pool;
|
|
7991
|
+
this.eventJournalService = new EventJournalService({
|
|
7992
|
+
capacity: 1e4,
|
|
7993
|
+
ttlMs: 0,
|
|
7994
|
+
persistent: true,
|
|
7995
|
+
pool,
|
|
7996
|
+
...config.eventJournalConfig
|
|
7997
|
+
});
|
|
7998
|
+
this.eventJournalService.initialize().then(() => {
|
|
7999
|
+
logger.info("EventJournalService initialized");
|
|
8000
|
+
}).catch((err) => {
|
|
8001
|
+
logger.error({ err }, "Failed to initialize EventJournalService");
|
|
8002
|
+
});
|
|
8003
|
+
}
|
|
6603
8004
|
this.systemManager = new SystemManager(
|
|
6604
8005
|
this.cluster,
|
|
6605
8006
|
this.metricsService,
|
|
@@ -6759,6 +8160,10 @@ var ServerCoordinator = class {
|
|
|
6759
8160
|
this.eventPayloadPool.clear();
|
|
6760
8161
|
this.taskletScheduler.shutdown();
|
|
6761
8162
|
this.writeAckManager.shutdown();
|
|
8163
|
+
this.entryProcessorHandler.dispose();
|
|
8164
|
+
if (this.eventJournalService) {
|
|
8165
|
+
this.eventJournalService.dispose();
|
|
8166
|
+
}
|
|
6762
8167
|
logger.info("Server Coordinator shutdown complete.");
|
|
6763
8168
|
}
|
|
6764
8169
|
async handleConnection(ws) {
|
|
@@ -6865,6 +8270,7 @@ var ServerCoordinator = class {
|
|
|
6865
8270
|
}
|
|
6866
8271
|
this.lockManager.handleClientDisconnect(clientId);
|
|
6867
8272
|
this.topicManager.unsubscribeAll(clientId);
|
|
8273
|
+
this.counterHandler.unsubscribeAll(clientId);
|
|
6868
8274
|
const members = this.cluster.getMembers();
|
|
6869
8275
|
for (const memberId of members) {
|
|
6870
8276
|
if (!this.cluster.isLocal(memberId)) {
|
|
@@ -7120,7 +8526,7 @@ var ServerCoordinator = class {
|
|
|
7120
8526
|
this.metricsService.incOp("GET", message.mapName);
|
|
7121
8527
|
try {
|
|
7122
8528
|
const mapForSync = await this.getMapAsync(message.mapName);
|
|
7123
|
-
if (mapForSync instanceof
|
|
8529
|
+
if (mapForSync instanceof LWWMap3) {
|
|
7124
8530
|
const tree = mapForSync.getMerkleTree();
|
|
7125
8531
|
const rootHash = tree.getRootHash();
|
|
7126
8532
|
client.writer.write({
|
|
@@ -7158,7 +8564,7 @@ var ServerCoordinator = class {
|
|
|
7158
8564
|
const { mapName, path } = message.payload;
|
|
7159
8565
|
try {
|
|
7160
8566
|
const mapForBucket = await this.getMapAsync(mapName);
|
|
7161
|
-
if (mapForBucket instanceof
|
|
8567
|
+
if (mapForBucket instanceof LWWMap3) {
|
|
7162
8568
|
const treeForBucket = mapForBucket.getMerkleTree();
|
|
7163
8569
|
const buckets = treeForBucket.getBuckets(path);
|
|
7164
8570
|
const node = treeForBucket.getNode(path);
|
|
@@ -7287,6 +8693,219 @@ var ServerCoordinator = class {
|
|
|
7287
8693
|
}
|
|
7288
8694
|
break;
|
|
7289
8695
|
}
|
|
8696
|
+
// ============ Phase 5.2: PN Counter Handlers ============
|
|
8697
|
+
case "COUNTER_REQUEST": {
|
|
8698
|
+
const { name } = message.payload;
|
|
8699
|
+
const response = this.counterHandler.handleCounterRequest(client.id, name);
|
|
8700
|
+
client.writer.write(response);
|
|
8701
|
+
logger.debug({ clientId: client.id, name }, "Counter request handled");
|
|
8702
|
+
break;
|
|
8703
|
+
}
|
|
8704
|
+
case "COUNTER_SYNC": {
|
|
8705
|
+
const { name, state } = message.payload;
|
|
8706
|
+
const result = this.counterHandler.handleCounterSync(client.id, name, state);
|
|
8707
|
+
client.writer.write(result.response);
|
|
8708
|
+
for (const targetClientId of result.broadcastTo) {
|
|
8709
|
+
const targetClient = this.clients.get(targetClientId);
|
|
8710
|
+
if (targetClient && targetClient.socket.readyState === WebSocket3.OPEN) {
|
|
8711
|
+
targetClient.writer.write(result.broadcastMessage);
|
|
8712
|
+
}
|
|
8713
|
+
}
|
|
8714
|
+
logger.debug({ clientId: client.id, name, broadcastCount: result.broadcastTo.length }, "Counter sync handled");
|
|
8715
|
+
break;
|
|
8716
|
+
}
|
|
8717
|
+
// ============ Phase 5.03: Entry Processor Handlers ============
|
|
8718
|
+
case "ENTRY_PROCESS": {
|
|
8719
|
+
const { requestId, mapName, key, processor } = message;
|
|
8720
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
8721
|
+
client.writer.write({
|
|
8722
|
+
type: "ENTRY_PROCESS_RESPONSE",
|
|
8723
|
+
requestId,
|
|
8724
|
+
success: false,
|
|
8725
|
+
error: `Access Denied for map ${mapName}`
|
|
8726
|
+
}, true);
|
|
8727
|
+
break;
|
|
8728
|
+
}
|
|
8729
|
+
const entryMap = this.getMap(mapName);
|
|
8730
|
+
const { result, timestamp } = await this.entryProcessorHandler.executeOnKey(
|
|
8731
|
+
entryMap,
|
|
8732
|
+
key,
|
|
8733
|
+
processor
|
|
8734
|
+
);
|
|
8735
|
+
client.writer.write({
|
|
8736
|
+
type: "ENTRY_PROCESS_RESPONSE",
|
|
8737
|
+
requestId,
|
|
8738
|
+
success: result.success,
|
|
8739
|
+
result: result.result,
|
|
8740
|
+
newValue: result.newValue,
|
|
8741
|
+
error: result.error
|
|
8742
|
+
});
|
|
8743
|
+
if (result.success && timestamp) {
|
|
8744
|
+
const record = entryMap.getRecord(key);
|
|
8745
|
+
if (record) {
|
|
8746
|
+
this.queryRegistry.processChange(mapName, entryMap, key, record, void 0);
|
|
8747
|
+
}
|
|
8748
|
+
}
|
|
8749
|
+
logger.debug({
|
|
8750
|
+
clientId: client.id,
|
|
8751
|
+
mapName,
|
|
8752
|
+
key,
|
|
8753
|
+
processor: processor.name,
|
|
8754
|
+
success: result.success
|
|
8755
|
+
}, "Entry processor executed");
|
|
8756
|
+
break;
|
|
8757
|
+
}
|
|
8758
|
+
case "ENTRY_PROCESS_BATCH": {
|
|
8759
|
+
const { requestId, mapName, keys, processor } = message;
|
|
8760
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
8761
|
+
const errorResults = {};
|
|
8762
|
+
for (const key of keys) {
|
|
8763
|
+
errorResults[key] = {
|
|
8764
|
+
success: false,
|
|
8765
|
+
error: `Access Denied for map ${mapName}`
|
|
8766
|
+
};
|
|
8767
|
+
}
|
|
8768
|
+
client.writer.write({
|
|
8769
|
+
type: "ENTRY_PROCESS_BATCH_RESPONSE",
|
|
8770
|
+
requestId,
|
|
8771
|
+
results: errorResults
|
|
8772
|
+
}, true);
|
|
8773
|
+
break;
|
|
8774
|
+
}
|
|
8775
|
+
const batchMap = this.getMap(mapName);
|
|
8776
|
+
const { results, timestamps } = await this.entryProcessorHandler.executeOnKeys(
|
|
8777
|
+
batchMap,
|
|
8778
|
+
keys,
|
|
8779
|
+
processor
|
|
8780
|
+
);
|
|
8781
|
+
const resultsRecord = {};
|
|
8782
|
+
for (const [key, keyResult] of results) {
|
|
8783
|
+
resultsRecord[key] = {
|
|
8784
|
+
success: keyResult.success,
|
|
8785
|
+
result: keyResult.result,
|
|
8786
|
+
newValue: keyResult.newValue,
|
|
8787
|
+
error: keyResult.error
|
|
8788
|
+
};
|
|
8789
|
+
}
|
|
8790
|
+
client.writer.write({
|
|
8791
|
+
type: "ENTRY_PROCESS_BATCH_RESPONSE",
|
|
8792
|
+
requestId,
|
|
8793
|
+
results: resultsRecord
|
|
8794
|
+
});
|
|
8795
|
+
for (const [key] of timestamps) {
|
|
8796
|
+
const record = batchMap.getRecord(key);
|
|
8797
|
+
if (record) {
|
|
8798
|
+
this.queryRegistry.processChange(mapName, batchMap, key, record, void 0);
|
|
8799
|
+
}
|
|
8800
|
+
}
|
|
8801
|
+
logger.debug({
|
|
8802
|
+
clientId: client.id,
|
|
8803
|
+
mapName,
|
|
8804
|
+
keyCount: keys.length,
|
|
8805
|
+
processor: processor.name,
|
|
8806
|
+
successCount: Array.from(results.values()).filter((r) => r.success).length
|
|
8807
|
+
}, "Entry processor batch executed");
|
|
8808
|
+
break;
|
|
8809
|
+
}
|
|
8810
|
+
// ============ Phase 5.05: Conflict Resolver Handlers ============
|
|
8811
|
+
case "REGISTER_RESOLVER": {
|
|
8812
|
+
const { requestId, mapName, resolver } = message;
|
|
8813
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
8814
|
+
client.writer.write({
|
|
8815
|
+
type: "REGISTER_RESOLVER_RESPONSE",
|
|
8816
|
+
requestId,
|
|
8817
|
+
success: false,
|
|
8818
|
+
error: `Access Denied for map ${mapName}`
|
|
8819
|
+
}, true);
|
|
8820
|
+
break;
|
|
8821
|
+
}
|
|
8822
|
+
try {
|
|
8823
|
+
this.conflictResolverHandler.registerResolver(
|
|
8824
|
+
mapName,
|
|
8825
|
+
{
|
|
8826
|
+
name: resolver.name,
|
|
8827
|
+
code: resolver.code,
|
|
8828
|
+
priority: resolver.priority,
|
|
8829
|
+
keyPattern: resolver.keyPattern
|
|
8830
|
+
},
|
|
8831
|
+
client.id
|
|
8832
|
+
);
|
|
8833
|
+
client.writer.write({
|
|
8834
|
+
type: "REGISTER_RESOLVER_RESPONSE",
|
|
8835
|
+
requestId,
|
|
8836
|
+
success: true
|
|
8837
|
+
});
|
|
8838
|
+
logger.info({
|
|
8839
|
+
clientId: client.id,
|
|
8840
|
+
mapName,
|
|
8841
|
+
resolverName: resolver.name,
|
|
8842
|
+
priority: resolver.priority
|
|
8843
|
+
}, "Conflict resolver registered");
|
|
8844
|
+
} catch (err) {
|
|
8845
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
8846
|
+
client.writer.write({
|
|
8847
|
+
type: "REGISTER_RESOLVER_RESPONSE",
|
|
8848
|
+
requestId,
|
|
8849
|
+
success: false,
|
|
8850
|
+
error: errorMessage
|
|
8851
|
+
}, true);
|
|
8852
|
+
logger.warn({
|
|
8853
|
+
clientId: client.id,
|
|
8854
|
+
mapName,
|
|
8855
|
+
error: errorMessage
|
|
8856
|
+
}, "Failed to register conflict resolver");
|
|
8857
|
+
}
|
|
8858
|
+
break;
|
|
8859
|
+
}
|
|
8860
|
+
case "UNREGISTER_RESOLVER": {
|
|
8861
|
+
const { requestId, mapName, resolverName } = message;
|
|
8862
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
8863
|
+
client.writer.write({
|
|
8864
|
+
type: "UNREGISTER_RESOLVER_RESPONSE",
|
|
8865
|
+
requestId,
|
|
8866
|
+
success: false,
|
|
8867
|
+
error: `Access Denied for map ${mapName}`
|
|
8868
|
+
}, true);
|
|
8869
|
+
break;
|
|
8870
|
+
}
|
|
8871
|
+
const removed = this.conflictResolverHandler.unregisterResolver(
|
|
8872
|
+
mapName,
|
|
8873
|
+
resolverName,
|
|
8874
|
+
client.id
|
|
8875
|
+
);
|
|
8876
|
+
client.writer.write({
|
|
8877
|
+
type: "UNREGISTER_RESOLVER_RESPONSE",
|
|
8878
|
+
requestId,
|
|
8879
|
+
success: removed,
|
|
8880
|
+
error: removed ? void 0 : "Resolver not found or not owned by this client"
|
|
8881
|
+
});
|
|
8882
|
+
if (removed) {
|
|
8883
|
+
logger.info({
|
|
8884
|
+
clientId: client.id,
|
|
8885
|
+
mapName,
|
|
8886
|
+
resolverName
|
|
8887
|
+
}, "Conflict resolver unregistered");
|
|
8888
|
+
}
|
|
8889
|
+
break;
|
|
8890
|
+
}
|
|
8891
|
+
case "LIST_RESOLVERS": {
|
|
8892
|
+
const { requestId, mapName } = message;
|
|
8893
|
+
if (mapName && !this.securityManager.checkPermission(client.principal, mapName, "READ")) {
|
|
8894
|
+
client.writer.write({
|
|
8895
|
+
type: "LIST_RESOLVERS_RESPONSE",
|
|
8896
|
+
requestId,
|
|
8897
|
+
resolvers: []
|
|
8898
|
+
});
|
|
8899
|
+
break;
|
|
8900
|
+
}
|
|
8901
|
+
const resolvers = this.conflictResolverHandler.listResolvers(mapName);
|
|
8902
|
+
client.writer.write({
|
|
8903
|
+
type: "LIST_RESOLVERS_RESPONSE",
|
|
8904
|
+
requestId,
|
|
8905
|
+
resolvers
|
|
8906
|
+
});
|
|
8907
|
+
break;
|
|
8908
|
+
}
|
|
7290
8909
|
// ============ Phase 4: Partition Map Request Handler ============
|
|
7291
8910
|
case "PARTITION_MAP_REQUEST": {
|
|
7292
8911
|
const clientVersion = message.payload?.currentVersion ?? 0;
|
|
@@ -7482,6 +9101,92 @@ var ServerCoordinator = class {
|
|
|
7482
9101
|
}
|
|
7483
9102
|
break;
|
|
7484
9103
|
}
|
|
9104
|
+
// === Event Journal Messages (Phase 5.04) ===
|
|
9105
|
+
case "JOURNAL_SUBSCRIBE": {
|
|
9106
|
+
if (!this.eventJournalService) {
|
|
9107
|
+
client.writer.write({
|
|
9108
|
+
type: "ERROR",
|
|
9109
|
+
payload: { code: 503, message: "Event journal not enabled" }
|
|
9110
|
+
}, true);
|
|
9111
|
+
break;
|
|
9112
|
+
}
|
|
9113
|
+
const { requestId, fromSequence, mapName, types } = message;
|
|
9114
|
+
const subscriptionId = requestId;
|
|
9115
|
+
this.journalSubscriptions.set(subscriptionId, {
|
|
9116
|
+
clientId: client.id,
|
|
9117
|
+
mapName,
|
|
9118
|
+
types
|
|
9119
|
+
});
|
|
9120
|
+
const unsubscribe = this.eventJournalService.subscribe(
|
|
9121
|
+
(event) => {
|
|
9122
|
+
if (mapName && event.mapName !== mapName) return;
|
|
9123
|
+
if (types && types.length > 0 && !types.includes(event.type)) return;
|
|
9124
|
+
const clientConn = this.clients.get(client.id);
|
|
9125
|
+
if (!clientConn) {
|
|
9126
|
+
unsubscribe();
|
|
9127
|
+
this.journalSubscriptions.delete(subscriptionId);
|
|
9128
|
+
return;
|
|
9129
|
+
}
|
|
9130
|
+
clientConn.writer.write({
|
|
9131
|
+
type: "JOURNAL_EVENT",
|
|
9132
|
+
event: {
|
|
9133
|
+
sequence: event.sequence.toString(),
|
|
9134
|
+
type: event.type,
|
|
9135
|
+
mapName: event.mapName,
|
|
9136
|
+
key: event.key,
|
|
9137
|
+
value: event.value,
|
|
9138
|
+
previousValue: event.previousValue,
|
|
9139
|
+
timestamp: event.timestamp,
|
|
9140
|
+
nodeId: event.nodeId,
|
|
9141
|
+
metadata: event.metadata
|
|
9142
|
+
}
|
|
9143
|
+
});
|
|
9144
|
+
},
|
|
9145
|
+
fromSequence ? BigInt(fromSequence) : void 0
|
|
9146
|
+
);
|
|
9147
|
+
logger.info({ clientId: client.id, subscriptionId, mapName }, "Journal subscription created");
|
|
9148
|
+
break;
|
|
9149
|
+
}
|
|
9150
|
+
case "JOURNAL_UNSUBSCRIBE": {
|
|
9151
|
+
const { subscriptionId } = message;
|
|
9152
|
+
this.journalSubscriptions.delete(subscriptionId);
|
|
9153
|
+
logger.info({ clientId: client.id, subscriptionId }, "Journal subscription removed");
|
|
9154
|
+
break;
|
|
9155
|
+
}
|
|
9156
|
+
case "JOURNAL_READ": {
|
|
9157
|
+
if (!this.eventJournalService) {
|
|
9158
|
+
client.writer.write({
|
|
9159
|
+
type: "ERROR",
|
|
9160
|
+
payload: { code: 503, message: "Event journal not enabled" }
|
|
9161
|
+
}, true);
|
|
9162
|
+
break;
|
|
9163
|
+
}
|
|
9164
|
+
const { requestId: readReqId, fromSequence: readFromSeq, limit, mapName: readMapName } = message;
|
|
9165
|
+
const startSeq = BigInt(readFromSeq);
|
|
9166
|
+
const eventLimit = limit ?? 100;
|
|
9167
|
+
let events = this.eventJournalService.readFrom(startSeq, eventLimit);
|
|
9168
|
+
if (readMapName) {
|
|
9169
|
+
events = events.filter((e) => e.mapName === readMapName);
|
|
9170
|
+
}
|
|
9171
|
+
const serializedEvents = events.map((e) => ({
|
|
9172
|
+
sequence: e.sequence.toString(),
|
|
9173
|
+
type: e.type,
|
|
9174
|
+
mapName: e.mapName,
|
|
9175
|
+
key: e.key,
|
|
9176
|
+
value: e.value,
|
|
9177
|
+
previousValue: e.previousValue,
|
|
9178
|
+
timestamp: e.timestamp,
|
|
9179
|
+
nodeId: e.nodeId,
|
|
9180
|
+
metadata: e.metadata
|
|
9181
|
+
}));
|
|
9182
|
+
client.writer.write({
|
|
9183
|
+
type: "JOURNAL_READ_RESPONSE",
|
|
9184
|
+
requestId: readReqId,
|
|
9185
|
+
events: serializedEvents,
|
|
9186
|
+
hasMore: events.length === eventLimit
|
|
9187
|
+
});
|
|
9188
|
+
break;
|
|
9189
|
+
}
|
|
7485
9190
|
default:
|
|
7486
9191
|
logger.warn({ type: message.type }, "Unknown message type");
|
|
7487
9192
|
}
|
|
@@ -7495,7 +9200,7 @@ var ServerCoordinator = class {
|
|
|
7495
9200
|
} else if (op.orRecord && op.orRecord.timestamp) {
|
|
7496
9201
|
} else if (op.orTag) {
|
|
7497
9202
|
try {
|
|
7498
|
-
ts =
|
|
9203
|
+
ts = HLC2.parse(op.orTag);
|
|
7499
9204
|
} catch (e) {
|
|
7500
9205
|
}
|
|
7501
9206
|
}
|
|
@@ -7529,6 +9234,39 @@ var ServerCoordinator = class {
|
|
|
7529
9234
|
clientCount: broadcastCount
|
|
7530
9235
|
}, "Broadcast partition map to clients");
|
|
7531
9236
|
}
|
|
9237
|
+
/**
|
|
9238
|
+
* Notify a client about a merge rejection (Phase 5.05).
|
|
9239
|
+
* Finds the client by node ID and sends MERGE_REJECTED message.
|
|
9240
|
+
*/
|
|
9241
|
+
notifyMergeRejection(rejection) {
|
|
9242
|
+
for (const [clientId, client] of this.clients) {
|
|
9243
|
+
if (clientId === rejection.nodeId || rejection.nodeId.includes(clientId)) {
|
|
9244
|
+
client.writer.write({
|
|
9245
|
+
type: "MERGE_REJECTED",
|
|
9246
|
+
mapName: rejection.mapName,
|
|
9247
|
+
key: rejection.key,
|
|
9248
|
+
attemptedValue: rejection.attemptedValue,
|
|
9249
|
+
reason: rejection.reason,
|
|
9250
|
+
timestamp: rejection.timestamp
|
|
9251
|
+
}, true);
|
|
9252
|
+
return;
|
|
9253
|
+
}
|
|
9254
|
+
}
|
|
9255
|
+
const subscribedClientIds = this.queryRegistry.getSubscribedClientIds(rejection.mapName);
|
|
9256
|
+
for (const clientId of subscribedClientIds) {
|
|
9257
|
+
const client = this.clients.get(clientId);
|
|
9258
|
+
if (client) {
|
|
9259
|
+
client.writer.write({
|
|
9260
|
+
type: "MERGE_REJECTED",
|
|
9261
|
+
mapName: rejection.mapName,
|
|
9262
|
+
key: rejection.key,
|
|
9263
|
+
attemptedValue: rejection.attemptedValue,
|
|
9264
|
+
reason: rejection.reason,
|
|
9265
|
+
timestamp: rejection.timestamp
|
|
9266
|
+
});
|
|
9267
|
+
}
|
|
9268
|
+
}
|
|
9269
|
+
}
|
|
7532
9270
|
broadcast(message, excludeClientId) {
|
|
7533
9271
|
const isServerEvent = message.type === "SERVER_EVENT";
|
|
7534
9272
|
if (isServerEvent) {
|
|
@@ -7866,7 +9604,7 @@ var ServerCoordinator = class {
|
|
|
7866
9604
|
async executeLocalQuery(mapName, query) {
|
|
7867
9605
|
const map = await this.getMapAsync(mapName);
|
|
7868
9606
|
const records = /* @__PURE__ */ new Map();
|
|
7869
|
-
if (map instanceof
|
|
9607
|
+
if (map instanceof LWWMap3) {
|
|
7870
9608
|
for (const key of map.allKeys()) {
|
|
7871
9609
|
const rec = map.getRecord(key);
|
|
7872
9610
|
if (rec && rec.value !== null) {
|
|
@@ -7940,10 +9678,10 @@ var ServerCoordinator = class {
|
|
|
7940
9678
|
*
|
|
7941
9679
|
* @returns Event payload for broadcasting (or null if operation failed)
|
|
7942
9680
|
*/
|
|
7943
|
-
applyOpToMap(op) {
|
|
9681
|
+
async applyOpToMap(op, remoteNodeId) {
|
|
7944
9682
|
const typeHint = op.opType === "OR_ADD" || op.opType === "OR_REMOVE" ? "OR" : "LWW";
|
|
7945
9683
|
const map = this.getMap(op.mapName, typeHint);
|
|
7946
|
-
if (typeHint === "OR" && map instanceof
|
|
9684
|
+
if (typeHint === "OR" && map instanceof LWWMap3) {
|
|
7947
9685
|
logger.error({ mapName: op.mapName }, "Map type mismatch: LWWMap but received OR op");
|
|
7948
9686
|
throw new Error("Map type mismatch: LWWMap but received OR op");
|
|
7949
9687
|
}
|
|
@@ -7958,12 +9696,34 @@ var ServerCoordinator = class {
|
|
|
7958
9696
|
mapName: op.mapName,
|
|
7959
9697
|
key: op.key
|
|
7960
9698
|
};
|
|
7961
|
-
if (map instanceof
|
|
9699
|
+
if (map instanceof LWWMap3) {
|
|
7962
9700
|
oldRecord = map.getRecord(op.key);
|
|
7963
|
-
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
|
|
9701
|
+
if (this.conflictResolverHandler.hasResolvers(op.mapName)) {
|
|
9702
|
+
const mergeResult = await this.conflictResolverHandler.mergeWithResolver(
|
|
9703
|
+
map,
|
|
9704
|
+
op.mapName,
|
|
9705
|
+
op.key,
|
|
9706
|
+
op.record,
|
|
9707
|
+
remoteNodeId || this._nodeId
|
|
9708
|
+
);
|
|
9709
|
+
if (!mergeResult.applied) {
|
|
9710
|
+
if (mergeResult.rejection) {
|
|
9711
|
+
logger.debug(
|
|
9712
|
+
{ mapName: op.mapName, key: op.key, reason: mergeResult.rejection.reason },
|
|
9713
|
+
"Merge rejected by resolver"
|
|
9714
|
+
);
|
|
9715
|
+
}
|
|
9716
|
+
return { eventPayload: null, oldRecord, rejected: true };
|
|
9717
|
+
}
|
|
9718
|
+
recordToStore = mergeResult.record;
|
|
9719
|
+
eventPayload.eventType = "UPDATED";
|
|
9720
|
+
eventPayload.record = mergeResult.record;
|
|
9721
|
+
} else {
|
|
9722
|
+
map.merge(op.key, op.record);
|
|
9723
|
+
recordToStore = op.record;
|
|
9724
|
+
eventPayload.eventType = "UPDATED";
|
|
9725
|
+
eventPayload.record = op.record;
|
|
9726
|
+
}
|
|
7967
9727
|
} else if (map instanceof ORMap2) {
|
|
7968
9728
|
oldRecord = map.getRecords(op.key);
|
|
7969
9729
|
if (op.opType === "OR_ADD") {
|
|
@@ -7994,6 +9754,21 @@ var ServerCoordinator = class {
|
|
|
7994
9754
|
});
|
|
7995
9755
|
}
|
|
7996
9756
|
}
|
|
9757
|
+
if (this.eventJournalService) {
|
|
9758
|
+
const isDelete = op.opType === "REMOVE" || op.opType === "OR_REMOVE" || op.record && op.record.value === null;
|
|
9759
|
+
const isNew = !oldRecord || Array.isArray(oldRecord) && oldRecord.length === 0;
|
|
9760
|
+
const journalEventType = isDelete ? "DELETE" : isNew ? "PUT" : "UPDATE";
|
|
9761
|
+
const timestamp = op.record?.timestamp || op.orRecord?.timestamp || this.hlc.now();
|
|
9762
|
+
this.eventJournalService.append({
|
|
9763
|
+
type: journalEventType,
|
|
9764
|
+
mapName: op.mapName,
|
|
9765
|
+
key: op.key,
|
|
9766
|
+
value: op.record?.value ?? op.orRecord?.value,
|
|
9767
|
+
previousValue: oldRecord?.value ?? (Array.isArray(oldRecord) ? oldRecord[0]?.value : void 0),
|
|
9768
|
+
timestamp,
|
|
9769
|
+
nodeId: this._nodeId
|
|
9770
|
+
});
|
|
9771
|
+
}
|
|
7997
9772
|
return { eventPayload, oldRecord };
|
|
7998
9773
|
}
|
|
7999
9774
|
/**
|
|
@@ -8015,7 +9790,10 @@ var ServerCoordinator = class {
|
|
|
8015
9790
|
try {
|
|
8016
9791
|
const op = operation;
|
|
8017
9792
|
logger.debug({ sourceNode, opId, mapName: op.mapName, key: op.key }, "Applying replicated operation");
|
|
8018
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
9793
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, sourceNode);
|
|
9794
|
+
if (rejected || !eventPayload) {
|
|
9795
|
+
return true;
|
|
9796
|
+
}
|
|
8019
9797
|
this.broadcast({
|
|
8020
9798
|
type: "SERVER_EVENT",
|
|
8021
9799
|
payload: eventPayload,
|
|
@@ -8114,7 +9892,10 @@ var ServerCoordinator = class {
|
|
|
8114
9892
|
logger.warn({ err, opId: op.id }, "Interceptor rejected op");
|
|
8115
9893
|
throw err;
|
|
8116
9894
|
}
|
|
8117
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
9895
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, originalSenderId);
|
|
9896
|
+
if (rejected || !eventPayload) {
|
|
9897
|
+
return;
|
|
9898
|
+
}
|
|
8118
9899
|
if (this.replicationPipeline && !fromCluster) {
|
|
8119
9900
|
const opId = op.id || `${op.mapName}:${op.key}:${Date.now()}`;
|
|
8120
9901
|
this.replicationPipeline.replicate(op, opId, op.key).catch((err) => {
|
|
@@ -8242,7 +10023,10 @@ var ServerCoordinator = class {
|
|
|
8242
10023
|
logger.warn({ err, opId: op.id }, "Interceptor rejected op in batch");
|
|
8243
10024
|
throw err;
|
|
8244
10025
|
}
|
|
8245
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
10026
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, clientId);
|
|
10027
|
+
if (rejected || !eventPayload) {
|
|
10028
|
+
return;
|
|
10029
|
+
}
|
|
8246
10030
|
if (this.replicationPipeline) {
|
|
8247
10031
|
const opId = op.id || `${op.mapName}:${op.key}:${Date.now()}`;
|
|
8248
10032
|
this.replicationPipeline.replicate(op, opId, op.key).catch((err) => {
|
|
@@ -8256,9 +10040,9 @@ var ServerCoordinator = class {
|
|
|
8256
10040
|
handleClusterEvent(payload) {
|
|
8257
10041
|
const { mapName, key, eventType } = payload;
|
|
8258
10042
|
const map = this.getMap(mapName, eventType === "OR_ADD" || eventType === "OR_REMOVE" ? "OR" : "LWW");
|
|
8259
|
-
const oldRecord = map instanceof
|
|
10043
|
+
const oldRecord = map instanceof LWWMap3 ? map.getRecord(key) : null;
|
|
8260
10044
|
if (this.partitionService.isRelated(key)) {
|
|
8261
|
-
if (map instanceof
|
|
10045
|
+
if (map instanceof LWWMap3 && payload.record) {
|
|
8262
10046
|
map.merge(key, payload.record);
|
|
8263
10047
|
} else if (map instanceof ORMap2) {
|
|
8264
10048
|
if (eventType === "OR_ADD" && payload.orRecord) {
|
|
@@ -8281,7 +10065,7 @@ var ServerCoordinator = class {
|
|
|
8281
10065
|
if (typeHint === "OR") {
|
|
8282
10066
|
map = new ORMap2(this.hlc);
|
|
8283
10067
|
} else {
|
|
8284
|
-
map = new
|
|
10068
|
+
map = new LWWMap3(this.hlc);
|
|
8285
10069
|
}
|
|
8286
10070
|
this.maps.set(name, map);
|
|
8287
10071
|
if (this.storage) {
|
|
@@ -8304,7 +10088,7 @@ var ServerCoordinator = class {
|
|
|
8304
10088
|
this.getMap(name, typeHint);
|
|
8305
10089
|
const loadingPromise = this.mapLoadingPromises.get(name);
|
|
8306
10090
|
const map = this.maps.get(name);
|
|
8307
|
-
const mapSize = map instanceof
|
|
10091
|
+
const mapSize = map instanceof LWWMap3 ? Array.from(map.entries()).length : map instanceof ORMap2 ? map.size : 0;
|
|
8308
10092
|
logger.info({
|
|
8309
10093
|
mapName: name,
|
|
8310
10094
|
mapExisted,
|
|
@@ -8314,7 +10098,7 @@ var ServerCoordinator = class {
|
|
|
8314
10098
|
if (loadingPromise) {
|
|
8315
10099
|
logger.info({ mapName: name }, "[getMapAsync] Waiting for loadMapFromStorage...");
|
|
8316
10100
|
await loadingPromise;
|
|
8317
|
-
const newMapSize = map instanceof
|
|
10101
|
+
const newMapSize = map instanceof LWWMap3 ? Array.from(map.entries()).length : map instanceof ORMap2 ? map.size : 0;
|
|
8318
10102
|
logger.info({ mapName: name, mapSizeAfterLoad: newMapSize }, "[getMapAsync] Load completed");
|
|
8319
10103
|
}
|
|
8320
10104
|
return this.maps.get(name);
|
|
@@ -8340,13 +10124,13 @@ var ServerCoordinator = class {
|
|
|
8340
10124
|
const currentMap = this.maps.get(name);
|
|
8341
10125
|
if (!currentMap) return;
|
|
8342
10126
|
let targetMap = currentMap;
|
|
8343
|
-
if (isOR && currentMap instanceof
|
|
10127
|
+
if (isOR && currentMap instanceof LWWMap3) {
|
|
8344
10128
|
logger.info({ mapName: name }, "Map auto-detected as ORMap. Switching type.");
|
|
8345
10129
|
targetMap = new ORMap2(this.hlc);
|
|
8346
10130
|
this.maps.set(name, targetMap);
|
|
8347
10131
|
} else if (!isOR && currentMap instanceof ORMap2 && typeHint !== "OR") {
|
|
8348
10132
|
logger.info({ mapName: name }, "Map auto-detected as LWWMap. Switching type.");
|
|
8349
|
-
targetMap = new
|
|
10133
|
+
targetMap = new LWWMap3(this.hlc);
|
|
8350
10134
|
this.maps.set(name, targetMap);
|
|
8351
10135
|
}
|
|
8352
10136
|
if (targetMap instanceof ORMap2) {
|
|
@@ -8362,7 +10146,7 @@ var ServerCoordinator = class {
|
|
|
8362
10146
|
}
|
|
8363
10147
|
}
|
|
8364
10148
|
}
|
|
8365
|
-
} else if (targetMap instanceof
|
|
10149
|
+
} else if (targetMap instanceof LWWMap3) {
|
|
8366
10150
|
for (const [key, record] of records) {
|
|
8367
10151
|
if (!record.type) {
|
|
8368
10152
|
targetMap.merge(key, record);
|
|
@@ -8455,7 +10239,7 @@ var ServerCoordinator = class {
|
|
|
8455
10239
|
reportLocalHlc() {
|
|
8456
10240
|
let minHlc = this.hlc.now();
|
|
8457
10241
|
for (const client of this.clients.values()) {
|
|
8458
|
-
if (
|
|
10242
|
+
if (HLC2.compare(client.lastActiveHlc, minHlc) < 0) {
|
|
8459
10243
|
minHlc = client.lastActiveHlc;
|
|
8460
10244
|
}
|
|
8461
10245
|
}
|
|
@@ -8476,7 +10260,7 @@ var ServerCoordinator = class {
|
|
|
8476
10260
|
let globalSafe = this.hlc.now();
|
|
8477
10261
|
let initialized = false;
|
|
8478
10262
|
for (const ts of this.gcReports.values()) {
|
|
8479
|
-
if (!initialized ||
|
|
10263
|
+
if (!initialized || HLC2.compare(ts, globalSafe) < 0) {
|
|
8480
10264
|
globalSafe = ts;
|
|
8481
10265
|
initialized = true;
|
|
8482
10266
|
}
|
|
@@ -8511,7 +10295,7 @@ var ServerCoordinator = class {
|
|
|
8511
10295
|
logger.info({ olderThanMillis: olderThan.millis }, "Performing Garbage Collection");
|
|
8512
10296
|
const now = Date.now();
|
|
8513
10297
|
for (const [name, map] of this.maps) {
|
|
8514
|
-
if (map instanceof
|
|
10298
|
+
if (map instanceof LWWMap3) {
|
|
8515
10299
|
for (const key of map.allKeys()) {
|
|
8516
10300
|
const record = map.getRecord(key);
|
|
8517
10301
|
if (record && record.value !== null && record.ttlMs) {
|
|
@@ -8803,7 +10587,13 @@ var ServerCoordinator = class {
|
|
|
8803
10587
|
}
|
|
8804
10588
|
return;
|
|
8805
10589
|
}
|
|
8806
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
10590
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, clientId);
|
|
10591
|
+
if (rejected) {
|
|
10592
|
+
if (op.id) {
|
|
10593
|
+
this.writeAckManager.failPending(op.id, "Rejected by conflict resolver");
|
|
10594
|
+
}
|
|
10595
|
+
return;
|
|
10596
|
+
}
|
|
8807
10597
|
if (op.id) {
|
|
8808
10598
|
this.writeAckManager.notifyLevel(op.id, WriteConcern2.APPLIED);
|
|
8809
10599
|
}
|
|
@@ -8885,9 +10675,9 @@ var ServerCoordinator = class {
|
|
|
8885
10675
|
// src/storage/PostgresAdapter.ts
|
|
8886
10676
|
import { Pool } from "pg";
|
|
8887
10677
|
var DEFAULT_TABLE_NAME = "topgun_maps";
|
|
8888
|
-
var
|
|
8889
|
-
function
|
|
8890
|
-
if (!
|
|
10678
|
+
var TABLE_NAME_REGEX2 = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
10679
|
+
function validateTableName2(name) {
|
|
10680
|
+
if (!TABLE_NAME_REGEX2.test(name)) {
|
|
8891
10681
|
throw new Error(
|
|
8892
10682
|
`Invalid table name "${name}". Table name must start with a letter or underscore and contain only alphanumeric characters and underscores.`
|
|
8893
10683
|
);
|
|
@@ -8901,7 +10691,7 @@ var PostgresAdapter = class {
|
|
|
8901
10691
|
this.pool = new Pool(configOrPool);
|
|
8902
10692
|
}
|
|
8903
10693
|
const tableName = options?.tableName ?? DEFAULT_TABLE_NAME;
|
|
8904
|
-
|
|
10694
|
+
validateTableName2(tableName);
|
|
8905
10695
|
this.tableName = tableName;
|
|
8906
10696
|
}
|
|
8907
10697
|
async initialize() {
|
|
@@ -9572,24 +11362,226 @@ var ClusterCoordinator = class extends EventEmitter9 {
|
|
|
9572
11362
|
}
|
|
9573
11363
|
}
|
|
9574
11364
|
};
|
|
11365
|
+
|
|
11366
|
+
// src/MapWithResolver.ts
|
|
11367
|
+
import {
|
|
11368
|
+
LWWMap as LWWMap4,
|
|
11369
|
+
HLC as HLC3
|
|
11370
|
+
} from "@topgunbuild/core";
|
|
11371
|
+
var MapWithResolver = class {
|
|
11372
|
+
constructor(config) {
|
|
11373
|
+
this.mapName = config.name;
|
|
11374
|
+
this.hlc = new HLC3(config.nodeId);
|
|
11375
|
+
this.map = new LWWMap4(this.hlc);
|
|
11376
|
+
this.resolverService = config.resolverService;
|
|
11377
|
+
this.onRejection = config.onRejection;
|
|
11378
|
+
}
|
|
11379
|
+
/**
|
|
11380
|
+
* Get the map name.
|
|
11381
|
+
*/
|
|
11382
|
+
get name() {
|
|
11383
|
+
return this.mapName;
|
|
11384
|
+
}
|
|
11385
|
+
/**
|
|
11386
|
+
* Get the underlying LWWMap.
|
|
11387
|
+
*/
|
|
11388
|
+
get rawMap() {
|
|
11389
|
+
return this.map;
|
|
11390
|
+
}
|
|
11391
|
+
/**
|
|
11392
|
+
* Get a value by key.
|
|
11393
|
+
*/
|
|
11394
|
+
get(key) {
|
|
11395
|
+
return this.map.get(key);
|
|
11396
|
+
}
|
|
11397
|
+
/**
|
|
11398
|
+
* Get the full record for a key.
|
|
11399
|
+
*/
|
|
11400
|
+
getRecord(key) {
|
|
11401
|
+
return this.map.getRecord(key);
|
|
11402
|
+
}
|
|
11403
|
+
/**
|
|
11404
|
+
* Get the timestamp for a key.
|
|
11405
|
+
*/
|
|
11406
|
+
getTimestamp(key) {
|
|
11407
|
+
return this.map.getRecord(key)?.timestamp;
|
|
11408
|
+
}
|
|
11409
|
+
/**
|
|
11410
|
+
* Set a value locally (no resolver).
|
|
11411
|
+
* Use for server-initiated writes.
|
|
11412
|
+
*/
|
|
11413
|
+
set(key, value, ttlMs) {
|
|
11414
|
+
return this.map.set(key, value, ttlMs);
|
|
11415
|
+
}
|
|
11416
|
+
/**
|
|
11417
|
+
* Set a value with conflict resolution.
|
|
11418
|
+
* Use for client-initiated writes.
|
|
11419
|
+
*
|
|
11420
|
+
* @param key The key to set
|
|
11421
|
+
* @param value The new value
|
|
11422
|
+
* @param timestamp The client's timestamp
|
|
11423
|
+
* @param remoteNodeId The client's node ID
|
|
11424
|
+
* @param auth Optional authentication context
|
|
11425
|
+
* @returns Result containing applied status and merge result
|
|
11426
|
+
*/
|
|
11427
|
+
async setWithResolver(key, value, timestamp, remoteNodeId, auth) {
|
|
11428
|
+
const context = {
|
|
11429
|
+
mapName: this.mapName,
|
|
11430
|
+
key,
|
|
11431
|
+
localValue: this.map.get(key),
|
|
11432
|
+
remoteValue: value,
|
|
11433
|
+
localTimestamp: this.getTimestamp(key),
|
|
11434
|
+
remoteTimestamp: timestamp,
|
|
11435
|
+
remoteNodeId,
|
|
11436
|
+
auth,
|
|
11437
|
+
readEntry: (k) => this.map.get(k)
|
|
11438
|
+
};
|
|
11439
|
+
const result = await this.resolverService.resolve(context);
|
|
11440
|
+
switch (result.action) {
|
|
11441
|
+
case "accept": {
|
|
11442
|
+
const record2 = {
|
|
11443
|
+
value: result.value,
|
|
11444
|
+
timestamp
|
|
11445
|
+
};
|
|
11446
|
+
this.map.merge(key, record2);
|
|
11447
|
+
return { applied: true, result, record: record2 };
|
|
11448
|
+
}
|
|
11449
|
+
case "merge": {
|
|
11450
|
+
const record2 = {
|
|
11451
|
+
value: result.value,
|
|
11452
|
+
timestamp
|
|
11453
|
+
};
|
|
11454
|
+
this.map.merge(key, record2);
|
|
11455
|
+
return { applied: true, result, record: record2 };
|
|
11456
|
+
}
|
|
11457
|
+
case "reject": {
|
|
11458
|
+
if (this.onRejection) {
|
|
11459
|
+
this.onRejection({
|
|
11460
|
+
mapName: this.mapName,
|
|
11461
|
+
key,
|
|
11462
|
+
attemptedValue: value,
|
|
11463
|
+
reason: result.reason,
|
|
11464
|
+
timestamp,
|
|
11465
|
+
nodeId: remoteNodeId
|
|
11466
|
+
});
|
|
11467
|
+
}
|
|
11468
|
+
return { applied: false, result };
|
|
11469
|
+
}
|
|
11470
|
+
case "local": {
|
|
11471
|
+
return { applied: false, result };
|
|
11472
|
+
}
|
|
11473
|
+
default:
|
|
11474
|
+
const record = {
|
|
11475
|
+
value: result.value ?? value,
|
|
11476
|
+
timestamp
|
|
11477
|
+
};
|
|
11478
|
+
this.map.merge(key, record);
|
|
11479
|
+
return { applied: true, result, record };
|
|
11480
|
+
}
|
|
11481
|
+
}
|
|
11482
|
+
/**
|
|
11483
|
+
* Remove a key.
|
|
11484
|
+
*/
|
|
11485
|
+
remove(key) {
|
|
11486
|
+
return this.map.remove(key);
|
|
11487
|
+
}
|
|
11488
|
+
/**
|
|
11489
|
+
* Standard merge without resolver (for sync operations).
|
|
11490
|
+
*/
|
|
11491
|
+
merge(key, record) {
|
|
11492
|
+
return this.map.merge(key, record);
|
|
11493
|
+
}
|
|
11494
|
+
/**
|
|
11495
|
+
* Merge with resolver support.
|
|
11496
|
+
* Equivalent to setWithResolver but takes a full record.
|
|
11497
|
+
*/
|
|
11498
|
+
async mergeWithResolver(key, record, remoteNodeId, auth) {
|
|
11499
|
+
if (record.value === null) {
|
|
11500
|
+
const applied = this.map.merge(key, record);
|
|
11501
|
+
return {
|
|
11502
|
+
applied,
|
|
11503
|
+
result: applied ? { action: "accept", value: record.value } : { action: "local" },
|
|
11504
|
+
record: applied ? record : void 0
|
|
11505
|
+
};
|
|
11506
|
+
}
|
|
11507
|
+
return this.setWithResolver(
|
|
11508
|
+
key,
|
|
11509
|
+
record.value,
|
|
11510
|
+
record.timestamp,
|
|
11511
|
+
remoteNodeId,
|
|
11512
|
+
auth
|
|
11513
|
+
);
|
|
11514
|
+
}
|
|
11515
|
+
/**
|
|
11516
|
+
* Clear all data.
|
|
11517
|
+
*/
|
|
11518
|
+
clear() {
|
|
11519
|
+
this.map.clear();
|
|
11520
|
+
}
|
|
11521
|
+
/**
|
|
11522
|
+
* Get map size.
|
|
11523
|
+
*/
|
|
11524
|
+
get size() {
|
|
11525
|
+
return this.map.size;
|
|
11526
|
+
}
|
|
11527
|
+
/**
|
|
11528
|
+
* Iterate over entries.
|
|
11529
|
+
*/
|
|
11530
|
+
entries() {
|
|
11531
|
+
return this.map.entries();
|
|
11532
|
+
}
|
|
11533
|
+
/**
|
|
11534
|
+
* Get all keys.
|
|
11535
|
+
*/
|
|
11536
|
+
allKeys() {
|
|
11537
|
+
return this.map.allKeys();
|
|
11538
|
+
}
|
|
11539
|
+
/**
|
|
11540
|
+
* Subscribe to changes.
|
|
11541
|
+
*/
|
|
11542
|
+
onChange(callback) {
|
|
11543
|
+
return this.map.onChange(callback);
|
|
11544
|
+
}
|
|
11545
|
+
/**
|
|
11546
|
+
* Get MerkleTree for sync.
|
|
11547
|
+
*/
|
|
11548
|
+
getMerkleTree() {
|
|
11549
|
+
return this.map.getMerkleTree();
|
|
11550
|
+
}
|
|
11551
|
+
/**
|
|
11552
|
+
* Prune old tombstones.
|
|
11553
|
+
*/
|
|
11554
|
+
prune(olderThan) {
|
|
11555
|
+
return this.map.prune(olderThan);
|
|
11556
|
+
}
|
|
11557
|
+
};
|
|
9575
11558
|
export {
|
|
9576
11559
|
BufferPool,
|
|
9577
11560
|
ClusterCoordinator,
|
|
9578
11561
|
ClusterManager,
|
|
11562
|
+
ConflictResolverHandler,
|
|
11563
|
+
ConflictResolverService,
|
|
9579
11564
|
ConnectionRateLimiter,
|
|
9580
11565
|
DEFAULT_CLUSTER_COORDINATOR_CONFIG,
|
|
11566
|
+
DEFAULT_CONFLICT_RESOLVER_CONFIG,
|
|
11567
|
+
DEFAULT_JOURNAL_SERVICE_CONFIG,
|
|
9581
11568
|
DEFAULT_LAG_TRACKER_CONFIG,
|
|
11569
|
+
DEFAULT_SANDBOX_CONFIG,
|
|
11570
|
+
EntryProcessorHandler,
|
|
11571
|
+
EventJournalService,
|
|
9582
11572
|
FilterTasklet,
|
|
9583
11573
|
ForEachTasklet,
|
|
9584
11574
|
IteratorTasklet,
|
|
9585
11575
|
LagTracker,
|
|
9586
11576
|
LockManager,
|
|
9587
11577
|
MapTasklet,
|
|
11578
|
+
MapWithResolver,
|
|
9588
11579
|
MemoryServerAdapter,
|
|
9589
11580
|
MigrationManager,
|
|
9590
11581
|
ObjectPool,
|
|
9591
11582
|
PartitionService,
|
|
9592
11583
|
PostgresAdapter,
|
|
11584
|
+
ProcessorSandbox,
|
|
9593
11585
|
RateLimitInterceptor,
|
|
9594
11586
|
ReduceTasklet,
|
|
9595
11587
|
ReplicationPipeline,
|