@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.js
CHANGED
|
@@ -33,20 +33,29 @@ __export(index_exports, {
|
|
|
33
33
|
BufferPool: () => BufferPool,
|
|
34
34
|
ClusterCoordinator: () => ClusterCoordinator,
|
|
35
35
|
ClusterManager: () => ClusterManager,
|
|
36
|
+
ConflictResolverHandler: () => ConflictResolverHandler,
|
|
37
|
+
ConflictResolverService: () => ConflictResolverService,
|
|
36
38
|
ConnectionRateLimiter: () => ConnectionRateLimiter,
|
|
37
39
|
DEFAULT_CLUSTER_COORDINATOR_CONFIG: () => DEFAULT_CLUSTER_COORDINATOR_CONFIG,
|
|
40
|
+
DEFAULT_CONFLICT_RESOLVER_CONFIG: () => DEFAULT_CONFLICT_RESOLVER_CONFIG,
|
|
41
|
+
DEFAULT_JOURNAL_SERVICE_CONFIG: () => DEFAULT_JOURNAL_SERVICE_CONFIG,
|
|
38
42
|
DEFAULT_LAG_TRACKER_CONFIG: () => DEFAULT_LAG_TRACKER_CONFIG,
|
|
43
|
+
DEFAULT_SANDBOX_CONFIG: () => DEFAULT_SANDBOX_CONFIG,
|
|
44
|
+
EntryProcessorHandler: () => EntryProcessorHandler,
|
|
45
|
+
EventJournalService: () => EventJournalService,
|
|
39
46
|
FilterTasklet: () => FilterTasklet,
|
|
40
47
|
ForEachTasklet: () => ForEachTasklet,
|
|
41
48
|
IteratorTasklet: () => IteratorTasklet,
|
|
42
49
|
LagTracker: () => LagTracker,
|
|
43
50
|
LockManager: () => LockManager,
|
|
44
51
|
MapTasklet: () => MapTasklet,
|
|
52
|
+
MapWithResolver: () => MapWithResolver,
|
|
45
53
|
MemoryServerAdapter: () => MemoryServerAdapter,
|
|
46
54
|
MigrationManager: () => MigrationManager,
|
|
47
55
|
ObjectPool: () => ObjectPool,
|
|
48
56
|
PartitionService: () => PartitionService,
|
|
49
57
|
PostgresAdapter: () => PostgresAdapter,
|
|
58
|
+
ProcessorSandbox: () => ProcessorSandbox,
|
|
50
59
|
RateLimitInterceptor: () => RateLimitInterceptor,
|
|
51
60
|
ReduceTasklet: () => ReduceTasklet,
|
|
52
61
|
ReplicationPipeline: () => ReplicationPipeline,
|
|
@@ -82,7 +91,7 @@ var import_http = require("http");
|
|
|
82
91
|
var import_https = require("https");
|
|
83
92
|
var import_fs2 = require("fs");
|
|
84
93
|
var import_ws3 = require("ws");
|
|
85
|
-
var
|
|
94
|
+
var import_core15 = require("@topgunbuild/core");
|
|
86
95
|
var jwt = __toESM(require("jsonwebtoken"));
|
|
87
96
|
var crypto = __toESM(require("crypto"));
|
|
88
97
|
|
|
@@ -6468,231 +6477,1621 @@ var ReplicationPipeline = class extends import_events8.EventEmitter {
|
|
|
6468
6477
|
}
|
|
6469
6478
|
};
|
|
6470
6479
|
|
|
6471
|
-
// src/
|
|
6472
|
-
var
|
|
6473
|
-
var
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
this.
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
const
|
|
6498
|
-
this.
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
name: `${config.nodeId}-event-executor`,
|
|
6507
|
-
onReject: (task) => {
|
|
6508
|
-
logger.warn({ nodeId: config.nodeId, key: task.key }, "Event task rejected due to queue capacity");
|
|
6509
|
-
this.metricsService.incEventQueueRejected();
|
|
6480
|
+
// src/handlers/CounterHandler.ts
|
|
6481
|
+
var import_core10 = require("@topgunbuild/core");
|
|
6482
|
+
var CounterHandler = class {
|
|
6483
|
+
// counterName -> Set<clientId>
|
|
6484
|
+
constructor(nodeId = "server") {
|
|
6485
|
+
this.nodeId = nodeId;
|
|
6486
|
+
this.counters = /* @__PURE__ */ new Map();
|
|
6487
|
+
this.subscriptions = /* @__PURE__ */ new Map();
|
|
6488
|
+
}
|
|
6489
|
+
/**
|
|
6490
|
+
* Get or create a counter by name.
|
|
6491
|
+
*/
|
|
6492
|
+
getOrCreateCounter(name) {
|
|
6493
|
+
let counter = this.counters.get(name);
|
|
6494
|
+
if (!counter) {
|
|
6495
|
+
counter = new import_core10.PNCounterImpl({ nodeId: this.nodeId });
|
|
6496
|
+
this.counters.set(name, counter);
|
|
6497
|
+
logger.debug({ name }, "Created new counter");
|
|
6498
|
+
}
|
|
6499
|
+
return counter;
|
|
6500
|
+
}
|
|
6501
|
+
/**
|
|
6502
|
+
* Handle COUNTER_REQUEST - client wants initial state.
|
|
6503
|
+
* @returns Response message to send back to client
|
|
6504
|
+
*/
|
|
6505
|
+
handleCounterRequest(clientId, name) {
|
|
6506
|
+
const counter = this.getOrCreateCounter(name);
|
|
6507
|
+
this.subscribe(clientId, name);
|
|
6508
|
+
const state = counter.getState();
|
|
6509
|
+
logger.debug({ clientId, name, value: counter.get() }, "Counter request handled");
|
|
6510
|
+
return {
|
|
6511
|
+
type: "COUNTER_RESPONSE",
|
|
6512
|
+
payload: {
|
|
6513
|
+
name,
|
|
6514
|
+
state: this.stateToObject(state)
|
|
6510
6515
|
}
|
|
6511
|
-
});
|
|
6512
|
-
this.backpressure = new BackpressureRegulator({
|
|
6513
|
-
syncFrequency: config.backpressureSyncFrequency ?? 100,
|
|
6514
|
-
maxPendingOps: config.backpressureMaxPending ?? 1e3,
|
|
6515
|
-
backoffTimeoutMs: config.backpressureBackoffMs ?? 5e3,
|
|
6516
|
-
enabled: config.backpressureEnabled ?? true
|
|
6517
|
-
});
|
|
6518
|
-
this.writeCoalescingEnabled = config.writeCoalescingEnabled ?? true;
|
|
6519
|
-
const preset = coalescingPresets[config.writeCoalescingPreset ?? "highThroughput"];
|
|
6520
|
-
this.writeCoalescingOptions = {
|
|
6521
|
-
maxBatchSize: config.writeCoalescingMaxBatch ?? preset.maxBatchSize,
|
|
6522
|
-
maxDelayMs: config.writeCoalescingMaxDelayMs ?? preset.maxDelayMs,
|
|
6523
|
-
maxBatchBytes: config.writeCoalescingMaxBytes ?? preset.maxBatchBytes
|
|
6524
6516
|
};
|
|
6525
|
-
|
|
6526
|
-
|
|
6527
|
-
|
|
6528
|
-
|
|
6529
|
-
|
|
6530
|
-
|
|
6531
|
-
|
|
6532
|
-
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
6537
|
-
|
|
6538
|
-
|
|
6539
|
-
|
|
6540
|
-
|
|
6541
|
-
|
|
6542
|
-
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
|
|
6546
|
-
|
|
6547
|
-
|
|
6548
|
-
|
|
6549
|
-
|
|
6550
|
-
|
|
6551
|
-
|
|
6552
|
-
|
|
6553
|
-
|
|
6554
|
-
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
this.httpServer = (0, import_https.createServer)(tlsOptions, (_req, res) => {
|
|
6561
|
-
res.writeHead(200);
|
|
6562
|
-
res.end("TopGun Server Running (Secure)");
|
|
6563
|
-
});
|
|
6564
|
-
logger.info("TLS enabled for client connections");
|
|
6565
|
-
} else {
|
|
6566
|
-
this.httpServer = (0, import_http.createServer)((_req, res) => {
|
|
6567
|
-
res.writeHead(200);
|
|
6568
|
-
res.end("TopGun Server Running");
|
|
6569
|
-
});
|
|
6570
|
-
if (process.env.NODE_ENV === "production") {
|
|
6571
|
-
logger.warn("\u26A0\uFE0F TLS is disabled! Client connections are NOT encrypted.");
|
|
6517
|
+
}
|
|
6518
|
+
/**
|
|
6519
|
+
* Handle COUNTER_SYNC - client sends their state to merge.
|
|
6520
|
+
* @returns Merged state and list of clients to broadcast to
|
|
6521
|
+
*/
|
|
6522
|
+
handleCounterSync(clientId, name, stateObj) {
|
|
6523
|
+
const counter = this.getOrCreateCounter(name);
|
|
6524
|
+
const incomingState = this.objectToState(stateObj);
|
|
6525
|
+
counter.merge(incomingState);
|
|
6526
|
+
const mergedState = counter.getState();
|
|
6527
|
+
const mergedStateObj = this.stateToObject(mergedState);
|
|
6528
|
+
logger.debug(
|
|
6529
|
+
{ clientId, name, value: counter.get() },
|
|
6530
|
+
"Counter sync handled"
|
|
6531
|
+
);
|
|
6532
|
+
this.subscribe(clientId, name);
|
|
6533
|
+
const subscribers = this.subscriptions.get(name) || /* @__PURE__ */ new Set();
|
|
6534
|
+
const broadcastTo = Array.from(subscribers).filter((id) => id !== clientId);
|
|
6535
|
+
return {
|
|
6536
|
+
// Response to the sending client
|
|
6537
|
+
response: {
|
|
6538
|
+
type: "COUNTER_UPDATE",
|
|
6539
|
+
payload: {
|
|
6540
|
+
name,
|
|
6541
|
+
state: mergedStateObj
|
|
6542
|
+
}
|
|
6543
|
+
},
|
|
6544
|
+
// Broadcast to other clients
|
|
6545
|
+
broadcastTo,
|
|
6546
|
+
broadcastMessage: {
|
|
6547
|
+
type: "COUNTER_UPDATE",
|
|
6548
|
+
payload: {
|
|
6549
|
+
name,
|
|
6550
|
+
state: mergedStateObj
|
|
6551
|
+
}
|
|
6572
6552
|
}
|
|
6553
|
+
};
|
|
6554
|
+
}
|
|
6555
|
+
/**
|
|
6556
|
+
* Subscribe a client to counter updates.
|
|
6557
|
+
*/
|
|
6558
|
+
subscribe(clientId, counterName) {
|
|
6559
|
+
if (!this.subscriptions.has(counterName)) {
|
|
6560
|
+
this.subscriptions.set(counterName, /* @__PURE__ */ new Set());
|
|
6573
6561
|
}
|
|
6574
|
-
|
|
6575
|
-
|
|
6576
|
-
|
|
6577
|
-
|
|
6578
|
-
|
|
6579
|
-
|
|
6580
|
-
|
|
6581
|
-
|
|
6582
|
-
|
|
6583
|
-
|
|
6584
|
-
|
|
6585
|
-
|
|
6586
|
-
res.end();
|
|
6562
|
+
this.subscriptions.get(counterName).add(clientId);
|
|
6563
|
+
logger.debug({ clientId, counterName }, "Client subscribed to counter");
|
|
6564
|
+
}
|
|
6565
|
+
/**
|
|
6566
|
+
* Unsubscribe a client from counter updates.
|
|
6567
|
+
*/
|
|
6568
|
+
unsubscribe(clientId, counterName) {
|
|
6569
|
+
const subs = this.subscriptions.get(counterName);
|
|
6570
|
+
if (subs) {
|
|
6571
|
+
subs.delete(clientId);
|
|
6572
|
+
if (subs.size === 0) {
|
|
6573
|
+
this.subscriptions.delete(counterName);
|
|
6587
6574
|
}
|
|
6588
|
-
}
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
backlog: config.wsBacklog ?? 511,
|
|
6599
|
-
// Disable per-message deflate by default (CPU overhead)
|
|
6600
|
-
perMessageDeflate: config.wsCompression ?? false,
|
|
6601
|
-
// Max payload size (64MB default)
|
|
6602
|
-
maxPayload: config.wsMaxPayload ?? 64 * 1024 * 1024,
|
|
6603
|
-
// Skip UTF-8 validation for binary messages (performance)
|
|
6604
|
-
skipUTF8Validation: true
|
|
6605
|
-
});
|
|
6606
|
-
this.wss.on("connection", (ws) => this.handleConnection(ws));
|
|
6607
|
-
this.httpServer.maxConnections = config.maxConnections ?? 1e4;
|
|
6608
|
-
this.httpServer.timeout = config.serverTimeout ?? 12e4;
|
|
6609
|
-
this.httpServer.keepAliveTimeout = config.keepAliveTimeout ?? 5e3;
|
|
6610
|
-
this.httpServer.headersTimeout = config.headersTimeout ?? 6e4;
|
|
6611
|
-
this.httpServer.on("connection", (socket) => {
|
|
6612
|
-
socket.setNoDelay(true);
|
|
6613
|
-
socket.setKeepAlive(true, 6e4);
|
|
6614
|
-
});
|
|
6615
|
-
this.httpServer.listen(config.port, () => {
|
|
6616
|
-
const addr = this.httpServer.address();
|
|
6617
|
-
this._actualPort = typeof addr === "object" && addr ? addr.port : config.port;
|
|
6618
|
-
logger.info({ port: this._actualPort }, "Server Coordinator listening");
|
|
6619
|
-
const clusterPort = config.clusterPort ?? 0;
|
|
6620
|
-
const peers = config.resolvePeers ? config.resolvePeers() : config.peers || [];
|
|
6621
|
-
this.cluster = new ClusterManager({
|
|
6622
|
-
nodeId: config.nodeId,
|
|
6623
|
-
host: config.host || "localhost",
|
|
6624
|
-
port: clusterPort,
|
|
6625
|
-
peers,
|
|
6626
|
-
discovery: config.discovery,
|
|
6627
|
-
serviceName: config.serviceName,
|
|
6628
|
-
discoveryInterval: config.discoveryInterval,
|
|
6629
|
-
tls: config.clusterTls
|
|
6630
|
-
});
|
|
6631
|
-
this.partitionService = new PartitionService(this.cluster);
|
|
6632
|
-
if (config.replicationEnabled !== false) {
|
|
6633
|
-
this.replicationPipeline = new ReplicationPipeline(
|
|
6634
|
-
this.cluster,
|
|
6635
|
-
this.partitionService,
|
|
6636
|
-
{
|
|
6637
|
-
...import_core10.DEFAULT_REPLICATION_CONFIG,
|
|
6638
|
-
defaultConsistency: config.defaultConsistency ?? import_core10.ConsistencyLevel.EVENTUAL,
|
|
6639
|
-
...config.replicationConfig
|
|
6640
|
-
}
|
|
6641
|
-
);
|
|
6642
|
-
this.replicationPipeline.setOperationApplier(this.applyReplicatedOperation.bind(this));
|
|
6643
|
-
logger.info({ nodeId: config.nodeId }, "ReplicationPipeline initialized");
|
|
6575
|
+
}
|
|
6576
|
+
}
|
|
6577
|
+
/**
|
|
6578
|
+
* Unsubscribe a client from all counters (e.g., on disconnect).
|
|
6579
|
+
*/
|
|
6580
|
+
unsubscribeAll(clientId) {
|
|
6581
|
+
for (const [counterName, subs] of this.subscriptions) {
|
|
6582
|
+
subs.delete(clientId);
|
|
6583
|
+
if (subs.size === 0) {
|
|
6584
|
+
this.subscriptions.delete(counterName);
|
|
6644
6585
|
}
|
|
6645
|
-
this.partitionService.on("rebalanced", (partitionMap, changes) => {
|
|
6646
|
-
this.broadcastPartitionMap(partitionMap);
|
|
6647
|
-
});
|
|
6648
|
-
this.lockManager = new LockManager();
|
|
6649
|
-
this.lockManager.on("lockGranted", (evt) => this.handleLockGranted(evt));
|
|
6650
|
-
this.topicManager = new TopicManager({
|
|
6651
|
-
cluster: this.cluster,
|
|
6652
|
-
sendToClient: (clientId, message) => {
|
|
6653
|
-
const client = this.clients.get(clientId);
|
|
6654
|
-
if (client && client.socket.readyState === import_ws3.WebSocket.OPEN) {
|
|
6655
|
-
client.writer.write(message);
|
|
6656
|
-
}
|
|
6657
|
-
}
|
|
6658
|
-
});
|
|
6659
|
-
this.systemManager = new SystemManager(
|
|
6660
|
-
this.cluster,
|
|
6661
|
-
this.metricsService,
|
|
6662
|
-
(name) => this.getMap(name)
|
|
6663
|
-
);
|
|
6664
|
-
this.setupClusterListeners();
|
|
6665
|
-
this.cluster.start().then((actualClusterPort) => {
|
|
6666
|
-
this._actualClusterPort = actualClusterPort;
|
|
6667
|
-
this.metricsService.setClusterMembers(this.cluster.getMembers().length);
|
|
6668
|
-
logger.info({ clusterPort: this._actualClusterPort }, "Cluster started");
|
|
6669
|
-
this.systemManager.start();
|
|
6670
|
-
this._readyResolve();
|
|
6671
|
-
}).catch((err) => {
|
|
6672
|
-
this._actualClusterPort = clusterPort;
|
|
6673
|
-
this.metricsService.setClusterMembers(this.cluster.getMembers().length);
|
|
6674
|
-
logger.info({ clusterPort: this._actualClusterPort }, "Cluster started (sync)");
|
|
6675
|
-
this.systemManager.start();
|
|
6676
|
-
this._readyResolve();
|
|
6677
|
-
});
|
|
6678
|
-
});
|
|
6679
|
-
if (this.storage) {
|
|
6680
|
-
this.storage.initialize().then(() => {
|
|
6681
|
-
logger.info("Storage adapter initialized");
|
|
6682
|
-
}).catch((err) => {
|
|
6683
|
-
logger.error({ err }, "Failed to initialize storage");
|
|
6684
|
-
});
|
|
6685
6586
|
}
|
|
6686
|
-
|
|
6687
|
-
this.startHeartbeatCheck();
|
|
6587
|
+
logger.debug({ clientId }, "Client unsubscribed from all counters");
|
|
6688
6588
|
}
|
|
6689
|
-
/**
|
|
6690
|
-
|
|
6691
|
-
|
|
6589
|
+
/**
|
|
6590
|
+
* Get current counter value (for monitoring/debugging).
|
|
6591
|
+
*/
|
|
6592
|
+
getCounterValue(name) {
|
|
6593
|
+
const counter = this.counters.get(name);
|
|
6594
|
+
return counter ? counter.get() : 0;
|
|
6692
6595
|
}
|
|
6693
6596
|
/**
|
|
6694
|
-
*
|
|
6695
|
-
|
|
6597
|
+
* Get all counter names.
|
|
6598
|
+
*/
|
|
6599
|
+
getCounterNames() {
|
|
6600
|
+
return Array.from(this.counters.keys());
|
|
6601
|
+
}
|
|
6602
|
+
/**
|
|
6603
|
+
* Get number of subscribers for a counter.
|
|
6604
|
+
*/
|
|
6605
|
+
getSubscriberCount(name) {
|
|
6606
|
+
return this.subscriptions.get(name)?.size || 0;
|
|
6607
|
+
}
|
|
6608
|
+
/**
|
|
6609
|
+
* Convert Map-based state to plain object for serialization.
|
|
6610
|
+
*/
|
|
6611
|
+
stateToObject(state) {
|
|
6612
|
+
return {
|
|
6613
|
+
p: Object.fromEntries(state.positive),
|
|
6614
|
+
n: Object.fromEntries(state.negative)
|
|
6615
|
+
};
|
|
6616
|
+
}
|
|
6617
|
+
/**
|
|
6618
|
+
* Convert plain object to Map-based state.
|
|
6619
|
+
*/
|
|
6620
|
+
objectToState(obj) {
|
|
6621
|
+
return {
|
|
6622
|
+
positive: new Map(Object.entries(obj.p || {})),
|
|
6623
|
+
negative: new Map(Object.entries(obj.n || {}))
|
|
6624
|
+
};
|
|
6625
|
+
}
|
|
6626
|
+
};
|
|
6627
|
+
|
|
6628
|
+
// src/handlers/EntryProcessorHandler.ts
|
|
6629
|
+
var import_core12 = require("@topgunbuild/core");
|
|
6630
|
+
|
|
6631
|
+
// src/ProcessorSandbox.ts
|
|
6632
|
+
var import_core11 = require("@topgunbuild/core");
|
|
6633
|
+
var ivm = null;
|
|
6634
|
+
try {
|
|
6635
|
+
ivm = require("isolated-vm");
|
|
6636
|
+
} catch {
|
|
6637
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
6638
|
+
if (isProduction) {
|
|
6639
|
+
logger.error(
|
|
6640
|
+
"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"
|
|
6641
|
+
);
|
|
6642
|
+
} else {
|
|
6643
|
+
logger.warn("isolated-vm not available, falling back to less secure VM");
|
|
6644
|
+
}
|
|
6645
|
+
}
|
|
6646
|
+
var DEFAULT_SANDBOX_CONFIG = {
|
|
6647
|
+
memoryLimitMb: 8,
|
|
6648
|
+
timeoutMs: 100,
|
|
6649
|
+
maxCachedIsolates: 100,
|
|
6650
|
+
strictValidation: true
|
|
6651
|
+
};
|
|
6652
|
+
var ProcessorSandbox = class {
|
|
6653
|
+
constructor(config = {}) {
|
|
6654
|
+
this.isolateCache = /* @__PURE__ */ new Map();
|
|
6655
|
+
this.scriptCache = /* @__PURE__ */ new Map();
|
|
6656
|
+
this.fallbackScriptCache = /* @__PURE__ */ new Map();
|
|
6657
|
+
this.disposed = false;
|
|
6658
|
+
this.config = { ...DEFAULT_SANDBOX_CONFIG, ...config };
|
|
6659
|
+
}
|
|
6660
|
+
/**
|
|
6661
|
+
* Execute an entry processor in the sandbox.
|
|
6662
|
+
*
|
|
6663
|
+
* @param processor The processor definition (name, code, args)
|
|
6664
|
+
* @param value The current value for the key (or undefined)
|
|
6665
|
+
* @param key The key being processed
|
|
6666
|
+
* @returns Result containing success status, result, and new value
|
|
6667
|
+
*/
|
|
6668
|
+
async execute(processor, value, key) {
|
|
6669
|
+
if (this.disposed) {
|
|
6670
|
+
return {
|
|
6671
|
+
success: false,
|
|
6672
|
+
error: "Sandbox has been disposed"
|
|
6673
|
+
};
|
|
6674
|
+
}
|
|
6675
|
+
if (this.config.strictValidation) {
|
|
6676
|
+
const validation = (0, import_core11.validateProcessorCode)(processor.code);
|
|
6677
|
+
if (!validation.valid) {
|
|
6678
|
+
return {
|
|
6679
|
+
success: false,
|
|
6680
|
+
error: validation.error
|
|
6681
|
+
};
|
|
6682
|
+
}
|
|
6683
|
+
}
|
|
6684
|
+
if (ivm) {
|
|
6685
|
+
return this.executeInIsolate(processor, value, key);
|
|
6686
|
+
} else {
|
|
6687
|
+
return this.executeInFallback(processor, value, key);
|
|
6688
|
+
}
|
|
6689
|
+
}
|
|
6690
|
+
/**
|
|
6691
|
+
* Execute processor in isolated-vm (secure production mode).
|
|
6692
|
+
*/
|
|
6693
|
+
async executeInIsolate(processor, value, key) {
|
|
6694
|
+
if (!ivm) {
|
|
6695
|
+
return { success: false, error: "isolated-vm not available" };
|
|
6696
|
+
}
|
|
6697
|
+
const isolate = this.getOrCreateIsolate(processor.name);
|
|
6698
|
+
try {
|
|
6699
|
+
const context = await isolate.createContext();
|
|
6700
|
+
const jail = context.global;
|
|
6701
|
+
await jail.set("global", jail.derefInto());
|
|
6702
|
+
await context.eval(`
|
|
6703
|
+
var value = ${JSON.stringify(value)};
|
|
6704
|
+
var key = ${JSON.stringify(key)};
|
|
6705
|
+
var args = ${JSON.stringify(processor.args)};
|
|
6706
|
+
`);
|
|
6707
|
+
const wrappedCode = `
|
|
6708
|
+
(function() {
|
|
6709
|
+
${processor.code}
|
|
6710
|
+
})()
|
|
6711
|
+
`;
|
|
6712
|
+
const script = await this.getOrCompileScript(
|
|
6713
|
+
processor.name,
|
|
6714
|
+
wrappedCode,
|
|
6715
|
+
isolate
|
|
6716
|
+
);
|
|
6717
|
+
const result = await script.run(context, {
|
|
6718
|
+
timeout: this.config.timeoutMs
|
|
6719
|
+
});
|
|
6720
|
+
const parsed = result;
|
|
6721
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
6722
|
+
return {
|
|
6723
|
+
success: false,
|
|
6724
|
+
error: "Processor must return { value, result? } object"
|
|
6725
|
+
};
|
|
6726
|
+
}
|
|
6727
|
+
return {
|
|
6728
|
+
success: true,
|
|
6729
|
+
result: parsed.result,
|
|
6730
|
+
newValue: parsed.value
|
|
6731
|
+
};
|
|
6732
|
+
} catch (error) {
|
|
6733
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6734
|
+
if (message.includes("Script execution timed out")) {
|
|
6735
|
+
return {
|
|
6736
|
+
success: false,
|
|
6737
|
+
error: "Processor execution timed out"
|
|
6738
|
+
};
|
|
6739
|
+
}
|
|
6740
|
+
return {
|
|
6741
|
+
success: false,
|
|
6742
|
+
error: message
|
|
6743
|
+
};
|
|
6744
|
+
}
|
|
6745
|
+
}
|
|
6746
|
+
/**
|
|
6747
|
+
* Execute processor in fallback VM (less secure, for development).
|
|
6748
|
+
*/
|
|
6749
|
+
async executeInFallback(processor, value, key) {
|
|
6750
|
+
try {
|
|
6751
|
+
const isResolver = processor.name.startsWith("resolver:");
|
|
6752
|
+
let fn = isResolver ? void 0 : this.fallbackScriptCache.get(processor.name);
|
|
6753
|
+
if (!fn) {
|
|
6754
|
+
const wrappedCode = `
|
|
6755
|
+
return (function(value, key, args) {
|
|
6756
|
+
${processor.code}
|
|
6757
|
+
})
|
|
6758
|
+
`;
|
|
6759
|
+
fn = new Function(wrappedCode)();
|
|
6760
|
+
if (!isResolver) {
|
|
6761
|
+
this.fallbackScriptCache.set(processor.name, fn);
|
|
6762
|
+
}
|
|
6763
|
+
}
|
|
6764
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
6765
|
+
setTimeout(() => reject(new Error("Processor execution timed out")), this.config.timeoutMs);
|
|
6766
|
+
});
|
|
6767
|
+
const executionPromise = Promise.resolve().then(() => fn(value, key, processor.args));
|
|
6768
|
+
const result = await Promise.race([executionPromise, timeoutPromise]);
|
|
6769
|
+
if (typeof result !== "object" || result === null) {
|
|
6770
|
+
return {
|
|
6771
|
+
success: false,
|
|
6772
|
+
error: "Processor must return { value, result? } object"
|
|
6773
|
+
};
|
|
6774
|
+
}
|
|
6775
|
+
return {
|
|
6776
|
+
success: true,
|
|
6777
|
+
result: result.result,
|
|
6778
|
+
newValue: result.value
|
|
6779
|
+
};
|
|
6780
|
+
} catch (error) {
|
|
6781
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6782
|
+
return {
|
|
6783
|
+
success: false,
|
|
6784
|
+
error: message
|
|
6785
|
+
};
|
|
6786
|
+
}
|
|
6787
|
+
}
|
|
6788
|
+
/**
|
|
6789
|
+
* Get or create an isolate for a processor.
|
|
6790
|
+
*/
|
|
6791
|
+
getOrCreateIsolate(name) {
|
|
6792
|
+
if (!ivm) {
|
|
6793
|
+
throw new Error("isolated-vm not available");
|
|
6794
|
+
}
|
|
6795
|
+
let isolate = this.isolateCache.get(name);
|
|
6796
|
+
if (!isolate || isolate.isDisposed) {
|
|
6797
|
+
if (this.isolateCache.size >= this.config.maxCachedIsolates) {
|
|
6798
|
+
const oldest = this.isolateCache.keys().next().value;
|
|
6799
|
+
if (oldest) {
|
|
6800
|
+
const oldIsolate = this.isolateCache.get(oldest);
|
|
6801
|
+
if (oldIsolate && !oldIsolate.isDisposed) {
|
|
6802
|
+
oldIsolate.dispose();
|
|
6803
|
+
}
|
|
6804
|
+
this.isolateCache.delete(oldest);
|
|
6805
|
+
this.scriptCache.delete(oldest);
|
|
6806
|
+
}
|
|
6807
|
+
}
|
|
6808
|
+
isolate = new ivm.Isolate({
|
|
6809
|
+
memoryLimit: this.config.memoryLimitMb
|
|
6810
|
+
});
|
|
6811
|
+
this.isolateCache.set(name, isolate);
|
|
6812
|
+
}
|
|
6813
|
+
return isolate;
|
|
6814
|
+
}
|
|
6815
|
+
/**
|
|
6816
|
+
* Get or compile a script for a processor.
|
|
6817
|
+
*/
|
|
6818
|
+
async getOrCompileScript(name, code, isolate) {
|
|
6819
|
+
let script = this.scriptCache.get(name);
|
|
6820
|
+
if (!script) {
|
|
6821
|
+
script = await isolate.compileScript(code);
|
|
6822
|
+
this.scriptCache.set(name, script);
|
|
6823
|
+
}
|
|
6824
|
+
return script;
|
|
6825
|
+
}
|
|
6826
|
+
/**
|
|
6827
|
+
* Clear script cache for a specific processor (e.g., when code changes).
|
|
6828
|
+
*/
|
|
6829
|
+
clearCache(processorName) {
|
|
6830
|
+
if (processorName) {
|
|
6831
|
+
const isolate = this.isolateCache.get(processorName);
|
|
6832
|
+
if (isolate && !isolate.isDisposed) {
|
|
6833
|
+
isolate.dispose();
|
|
6834
|
+
}
|
|
6835
|
+
this.isolateCache.delete(processorName);
|
|
6836
|
+
this.scriptCache.delete(processorName);
|
|
6837
|
+
this.fallbackScriptCache.delete(processorName);
|
|
6838
|
+
} else {
|
|
6839
|
+
for (const isolate of this.isolateCache.values()) {
|
|
6840
|
+
if (!isolate.isDisposed) {
|
|
6841
|
+
isolate.dispose();
|
|
6842
|
+
}
|
|
6843
|
+
}
|
|
6844
|
+
this.isolateCache.clear();
|
|
6845
|
+
this.scriptCache.clear();
|
|
6846
|
+
this.fallbackScriptCache.clear();
|
|
6847
|
+
}
|
|
6848
|
+
}
|
|
6849
|
+
/**
|
|
6850
|
+
* Check if using secure isolated-vm mode.
|
|
6851
|
+
*/
|
|
6852
|
+
isSecureMode() {
|
|
6853
|
+
return ivm !== null;
|
|
6854
|
+
}
|
|
6855
|
+
/**
|
|
6856
|
+
* Get current cache sizes.
|
|
6857
|
+
*/
|
|
6858
|
+
getCacheStats() {
|
|
6859
|
+
return {
|
|
6860
|
+
isolates: this.isolateCache.size,
|
|
6861
|
+
scripts: this.scriptCache.size,
|
|
6862
|
+
fallbackScripts: this.fallbackScriptCache.size
|
|
6863
|
+
};
|
|
6864
|
+
}
|
|
6865
|
+
/**
|
|
6866
|
+
* Dispose of all isolates and clear caches.
|
|
6867
|
+
*/
|
|
6868
|
+
dispose() {
|
|
6869
|
+
if (this.disposed) return;
|
|
6870
|
+
this.disposed = true;
|
|
6871
|
+
this.clearCache();
|
|
6872
|
+
logger.debug("ProcessorSandbox disposed");
|
|
6873
|
+
}
|
|
6874
|
+
};
|
|
6875
|
+
|
|
6876
|
+
// src/handlers/EntryProcessorHandler.ts
|
|
6877
|
+
var EntryProcessorHandler = class {
|
|
6878
|
+
constructor(config) {
|
|
6879
|
+
this.hlc = config.hlc;
|
|
6880
|
+
this.sandbox = new ProcessorSandbox(config.sandboxConfig);
|
|
6881
|
+
}
|
|
6882
|
+
/**
|
|
6883
|
+
* Execute a processor on a single key atomically.
|
|
6884
|
+
*
|
|
6885
|
+
* @param map The LWWMap to operate on
|
|
6886
|
+
* @param key The key to process
|
|
6887
|
+
* @param processorDef The processor definition (will be validated)
|
|
6888
|
+
* @returns Result with success status, processor result, and new value
|
|
6889
|
+
*/
|
|
6890
|
+
async executeOnKey(map, key, processorDef) {
|
|
6891
|
+
const parseResult = import_core12.EntryProcessorDefSchema.safeParse(processorDef);
|
|
6892
|
+
if (!parseResult.success) {
|
|
6893
|
+
logger.warn(
|
|
6894
|
+
{ key, error: parseResult.error.message },
|
|
6895
|
+
"Invalid processor definition"
|
|
6896
|
+
);
|
|
6897
|
+
return {
|
|
6898
|
+
result: {
|
|
6899
|
+
success: false,
|
|
6900
|
+
error: `Invalid processor: ${parseResult.error.message}`
|
|
6901
|
+
}
|
|
6902
|
+
};
|
|
6903
|
+
}
|
|
6904
|
+
const processor = parseResult.data;
|
|
6905
|
+
const currentValue = map.get(key);
|
|
6906
|
+
logger.debug(
|
|
6907
|
+
{ key, processor: processor.name, hasValue: currentValue !== void 0 },
|
|
6908
|
+
"Executing entry processor"
|
|
6909
|
+
);
|
|
6910
|
+
const sandboxResult = await this.sandbox.execute(
|
|
6911
|
+
processor,
|
|
6912
|
+
currentValue,
|
|
6913
|
+
key
|
|
6914
|
+
);
|
|
6915
|
+
if (!sandboxResult.success) {
|
|
6916
|
+
logger.warn(
|
|
6917
|
+
{ key, processor: processor.name, error: sandboxResult.error },
|
|
6918
|
+
"Processor execution failed"
|
|
6919
|
+
);
|
|
6920
|
+
return { result: sandboxResult };
|
|
6921
|
+
}
|
|
6922
|
+
let timestamp;
|
|
6923
|
+
if (sandboxResult.newValue !== void 0) {
|
|
6924
|
+
const record = map.set(key, sandboxResult.newValue);
|
|
6925
|
+
timestamp = record.timestamp;
|
|
6926
|
+
logger.debug(
|
|
6927
|
+
{ key, processor: processor.name, timestamp },
|
|
6928
|
+
"Processor updated value"
|
|
6929
|
+
);
|
|
6930
|
+
} else if (currentValue !== void 0) {
|
|
6931
|
+
const tombstone = map.remove(key);
|
|
6932
|
+
timestamp = tombstone.timestamp;
|
|
6933
|
+
logger.debug(
|
|
6934
|
+
{ key, processor: processor.name, timestamp },
|
|
6935
|
+
"Processor deleted value"
|
|
6936
|
+
);
|
|
6937
|
+
}
|
|
6938
|
+
return {
|
|
6939
|
+
result: sandboxResult,
|
|
6940
|
+
timestamp
|
|
6941
|
+
};
|
|
6942
|
+
}
|
|
6943
|
+
/**
|
|
6944
|
+
* Execute a processor on multiple keys.
|
|
6945
|
+
*
|
|
6946
|
+
* Each key is processed sequentially to ensure atomicity per-key.
|
|
6947
|
+
* For parallel execution across keys, use multiple calls.
|
|
6948
|
+
*
|
|
6949
|
+
* @param map The LWWMap to operate on
|
|
6950
|
+
* @param keys The keys to process
|
|
6951
|
+
* @param processorDef The processor definition
|
|
6952
|
+
* @returns Map of key -> result
|
|
6953
|
+
*/
|
|
6954
|
+
async executeOnKeys(map, keys, processorDef) {
|
|
6955
|
+
const results = /* @__PURE__ */ new Map();
|
|
6956
|
+
const timestamps = /* @__PURE__ */ new Map();
|
|
6957
|
+
const parseResult = import_core12.EntryProcessorDefSchema.safeParse(processorDef);
|
|
6958
|
+
if (!parseResult.success) {
|
|
6959
|
+
const errorResult = {
|
|
6960
|
+
success: false,
|
|
6961
|
+
error: `Invalid processor: ${parseResult.error.message}`
|
|
6962
|
+
};
|
|
6963
|
+
for (const key of keys) {
|
|
6964
|
+
results.set(key, errorResult);
|
|
6965
|
+
}
|
|
6966
|
+
return { results, timestamps };
|
|
6967
|
+
}
|
|
6968
|
+
for (const key of keys) {
|
|
6969
|
+
const { result, timestamp } = await this.executeOnKey(
|
|
6970
|
+
map,
|
|
6971
|
+
key,
|
|
6972
|
+
processorDef
|
|
6973
|
+
);
|
|
6974
|
+
results.set(key, result);
|
|
6975
|
+
if (timestamp) {
|
|
6976
|
+
timestamps.set(key, timestamp);
|
|
6977
|
+
}
|
|
6978
|
+
}
|
|
6979
|
+
return { results, timestamps };
|
|
6980
|
+
}
|
|
6981
|
+
/**
|
|
6982
|
+
* Execute a processor on all entries matching a predicate.
|
|
6983
|
+
*
|
|
6984
|
+
* WARNING: This can be expensive for large maps.
|
|
6985
|
+
*
|
|
6986
|
+
* @param map The LWWMap to operate on
|
|
6987
|
+
* @param processorDef The processor definition
|
|
6988
|
+
* @param predicateCode Optional predicate code to filter entries
|
|
6989
|
+
* @returns Map of key -> result for processed entries
|
|
6990
|
+
*/
|
|
6991
|
+
async executeOnEntries(map, processorDef, predicateCode) {
|
|
6992
|
+
const results = /* @__PURE__ */ new Map();
|
|
6993
|
+
const timestamps = /* @__PURE__ */ new Map();
|
|
6994
|
+
const parseResult = import_core12.EntryProcessorDefSchema.safeParse(processorDef);
|
|
6995
|
+
if (!parseResult.success) {
|
|
6996
|
+
return { results, timestamps };
|
|
6997
|
+
}
|
|
6998
|
+
const entries = map.entries();
|
|
6999
|
+
for (const [key, value] of entries) {
|
|
7000
|
+
if (predicateCode) {
|
|
7001
|
+
const predicateResult = await this.sandbox.execute(
|
|
7002
|
+
{
|
|
7003
|
+
name: "_predicate",
|
|
7004
|
+
code: `return { value, result: (function() { ${predicateCode} })() };`
|
|
7005
|
+
},
|
|
7006
|
+
value,
|
|
7007
|
+
key
|
|
7008
|
+
);
|
|
7009
|
+
if (!predicateResult.success || !predicateResult.result) {
|
|
7010
|
+
continue;
|
|
7011
|
+
}
|
|
7012
|
+
}
|
|
7013
|
+
const { result, timestamp } = await this.executeOnKey(
|
|
7014
|
+
map,
|
|
7015
|
+
key,
|
|
7016
|
+
processorDef
|
|
7017
|
+
);
|
|
7018
|
+
results.set(key, result);
|
|
7019
|
+
if (timestamp) {
|
|
7020
|
+
timestamps.set(key, timestamp);
|
|
7021
|
+
}
|
|
7022
|
+
}
|
|
7023
|
+
return { results, timestamps };
|
|
7024
|
+
}
|
|
7025
|
+
/**
|
|
7026
|
+
* Check if sandbox is in secure mode (using isolated-vm).
|
|
7027
|
+
*/
|
|
7028
|
+
isSecureMode() {
|
|
7029
|
+
return this.sandbox.isSecureMode();
|
|
7030
|
+
}
|
|
7031
|
+
/**
|
|
7032
|
+
* Get sandbox cache statistics.
|
|
7033
|
+
*/
|
|
7034
|
+
getCacheStats() {
|
|
7035
|
+
return this.sandbox.getCacheStats();
|
|
7036
|
+
}
|
|
7037
|
+
/**
|
|
7038
|
+
* Clear sandbox cache.
|
|
7039
|
+
*/
|
|
7040
|
+
clearCache(processorName) {
|
|
7041
|
+
this.sandbox.clearCache(processorName);
|
|
7042
|
+
}
|
|
7043
|
+
/**
|
|
7044
|
+
* Dispose of the handler and its sandbox.
|
|
7045
|
+
*/
|
|
7046
|
+
dispose() {
|
|
7047
|
+
this.sandbox.dispose();
|
|
7048
|
+
logger.debug("EntryProcessorHandler disposed");
|
|
7049
|
+
}
|
|
7050
|
+
};
|
|
7051
|
+
|
|
7052
|
+
// src/ConflictResolverService.ts
|
|
7053
|
+
var import_core13 = require("@topgunbuild/core");
|
|
7054
|
+
var DEFAULT_CONFLICT_RESOLVER_CONFIG = {
|
|
7055
|
+
maxResolversPerMap: 100,
|
|
7056
|
+
enableSandboxedResolvers: true,
|
|
7057
|
+
resolverTimeoutMs: 100
|
|
7058
|
+
};
|
|
7059
|
+
var ConflictResolverService = class {
|
|
7060
|
+
constructor(sandbox, config = {}) {
|
|
7061
|
+
this.resolvers = /* @__PURE__ */ new Map();
|
|
7062
|
+
this.disposed = false;
|
|
7063
|
+
this.sandbox = sandbox;
|
|
7064
|
+
this.config = { ...DEFAULT_CONFLICT_RESOLVER_CONFIG, ...config };
|
|
7065
|
+
}
|
|
7066
|
+
/**
|
|
7067
|
+
* Set callback for merge rejections.
|
|
7068
|
+
*/
|
|
7069
|
+
onRejection(callback) {
|
|
7070
|
+
this.onRejectionCallback = callback;
|
|
7071
|
+
}
|
|
7072
|
+
/**
|
|
7073
|
+
* Register a resolver for a map.
|
|
7074
|
+
*
|
|
7075
|
+
* @param mapName The map this resolver applies to
|
|
7076
|
+
* @param resolver The resolver definition
|
|
7077
|
+
* @param registeredBy Optional client ID that registered this resolver
|
|
7078
|
+
*/
|
|
7079
|
+
register(mapName, resolver, registeredBy) {
|
|
7080
|
+
if (this.disposed) {
|
|
7081
|
+
throw new Error("ConflictResolverService has been disposed");
|
|
7082
|
+
}
|
|
7083
|
+
if (resolver.code) {
|
|
7084
|
+
const parsed = import_core13.ConflictResolverDefSchema.safeParse({
|
|
7085
|
+
name: resolver.name,
|
|
7086
|
+
code: resolver.code,
|
|
7087
|
+
priority: resolver.priority,
|
|
7088
|
+
keyPattern: resolver.keyPattern
|
|
7089
|
+
});
|
|
7090
|
+
if (!parsed.success) {
|
|
7091
|
+
throw new Error(`Invalid resolver definition: ${parsed.error.message}`);
|
|
7092
|
+
}
|
|
7093
|
+
const validation = (0, import_core13.validateResolverCode)(resolver.code);
|
|
7094
|
+
if (!validation.valid) {
|
|
7095
|
+
throw new Error(`Invalid resolver code: ${validation.error}`);
|
|
7096
|
+
}
|
|
7097
|
+
}
|
|
7098
|
+
const entries = this.resolvers.get(mapName) ?? [];
|
|
7099
|
+
if (entries.length >= this.config.maxResolversPerMap) {
|
|
7100
|
+
throw new Error(
|
|
7101
|
+
`Maximum resolvers per map (${this.config.maxResolversPerMap}) exceeded`
|
|
7102
|
+
);
|
|
7103
|
+
}
|
|
7104
|
+
const filtered = entries.filter((e) => e.resolver.name !== resolver.name);
|
|
7105
|
+
const entry = {
|
|
7106
|
+
resolver,
|
|
7107
|
+
registeredBy
|
|
7108
|
+
};
|
|
7109
|
+
if (resolver.code && !resolver.fn && this.config.enableSandboxedResolvers) {
|
|
7110
|
+
entry.compiledFn = this.compileSandboxed(resolver.name, resolver.code);
|
|
7111
|
+
}
|
|
7112
|
+
filtered.push(entry);
|
|
7113
|
+
filtered.sort(
|
|
7114
|
+
(a, b) => (b.resolver.priority ?? 50) - (a.resolver.priority ?? 50)
|
|
7115
|
+
);
|
|
7116
|
+
this.resolvers.set(mapName, filtered);
|
|
7117
|
+
logger.debug(
|
|
7118
|
+
`Registered resolver '${resolver.name}' for map '${mapName}' with priority ${resolver.priority ?? 50}`
|
|
7119
|
+
);
|
|
7120
|
+
}
|
|
7121
|
+
/**
|
|
7122
|
+
* Unregister a resolver.
|
|
7123
|
+
*
|
|
7124
|
+
* @param mapName The map name
|
|
7125
|
+
* @param resolverName The resolver name to unregister
|
|
7126
|
+
* @param clientId Optional - only unregister if registered by this client
|
|
7127
|
+
*/
|
|
7128
|
+
unregister(mapName, resolverName, clientId) {
|
|
7129
|
+
const entries = this.resolvers.get(mapName);
|
|
7130
|
+
if (!entries) return false;
|
|
7131
|
+
const entryIndex = entries.findIndex(
|
|
7132
|
+
(e) => e.resolver.name === resolverName && (!clientId || e.registeredBy === clientId)
|
|
7133
|
+
);
|
|
7134
|
+
if (entryIndex === -1) return false;
|
|
7135
|
+
entries.splice(entryIndex, 1);
|
|
7136
|
+
if (entries.length === 0) {
|
|
7137
|
+
this.resolvers.delete(mapName);
|
|
7138
|
+
}
|
|
7139
|
+
logger.debug(`Unregistered resolver '${resolverName}' from map '${mapName}'`);
|
|
7140
|
+
return true;
|
|
7141
|
+
}
|
|
7142
|
+
/**
|
|
7143
|
+
* Resolve a merge conflict using registered resolvers.
|
|
7144
|
+
*
|
|
7145
|
+
* @param context The merge context
|
|
7146
|
+
* @returns The merge result
|
|
7147
|
+
*/
|
|
7148
|
+
async resolve(context) {
|
|
7149
|
+
if (this.disposed) {
|
|
7150
|
+
return { action: "accept", value: context.remoteValue };
|
|
7151
|
+
}
|
|
7152
|
+
const entries = this.resolvers.get(context.mapName) ?? [];
|
|
7153
|
+
const allEntries = [
|
|
7154
|
+
...entries,
|
|
7155
|
+
{ resolver: import_core13.BuiltInResolvers.LWW() }
|
|
7156
|
+
];
|
|
7157
|
+
for (const entry of allEntries) {
|
|
7158
|
+
const { resolver } = entry;
|
|
7159
|
+
if (resolver.keyPattern && !this.matchKeyPattern(context.key, resolver.keyPattern)) {
|
|
7160
|
+
continue;
|
|
7161
|
+
}
|
|
7162
|
+
try {
|
|
7163
|
+
let result;
|
|
7164
|
+
if (resolver.fn) {
|
|
7165
|
+
const fn = resolver.fn;
|
|
7166
|
+
const maybePromise = fn(context);
|
|
7167
|
+
result = maybePromise instanceof Promise ? await maybePromise : maybePromise;
|
|
7168
|
+
} else if (entry.compiledFn) {
|
|
7169
|
+
const compiledFn = entry.compiledFn;
|
|
7170
|
+
result = await compiledFn(context);
|
|
7171
|
+
} else {
|
|
7172
|
+
continue;
|
|
7173
|
+
}
|
|
7174
|
+
if (result.action !== "local") {
|
|
7175
|
+
if (result.action === "reject") {
|
|
7176
|
+
logger.debug(
|
|
7177
|
+
`Resolver '${resolver.name}' rejected merge for key '${context.key}' in map '${context.mapName}': ${result.reason}`
|
|
7178
|
+
);
|
|
7179
|
+
if (this.onRejectionCallback) {
|
|
7180
|
+
this.onRejectionCallback({
|
|
7181
|
+
mapName: context.mapName,
|
|
7182
|
+
key: context.key,
|
|
7183
|
+
attemptedValue: context.remoteValue,
|
|
7184
|
+
reason: result.reason,
|
|
7185
|
+
timestamp: context.remoteTimestamp,
|
|
7186
|
+
nodeId: context.remoteNodeId
|
|
7187
|
+
});
|
|
7188
|
+
}
|
|
7189
|
+
}
|
|
7190
|
+
return result;
|
|
7191
|
+
}
|
|
7192
|
+
} catch (error) {
|
|
7193
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
7194
|
+
logger.error(`Resolver '${resolver.name}' threw error: ${message}`);
|
|
7195
|
+
}
|
|
7196
|
+
}
|
|
7197
|
+
return { action: "accept", value: context.remoteValue };
|
|
7198
|
+
}
|
|
7199
|
+
/**
|
|
7200
|
+
* List registered resolvers.
|
|
7201
|
+
*
|
|
7202
|
+
* @param mapName Optional - filter by map name
|
|
7203
|
+
*/
|
|
7204
|
+
list(mapName) {
|
|
7205
|
+
const result = [];
|
|
7206
|
+
if (mapName) {
|
|
7207
|
+
const entries = this.resolvers.get(mapName) ?? [];
|
|
7208
|
+
for (const entry of entries) {
|
|
7209
|
+
result.push({
|
|
7210
|
+
mapName,
|
|
7211
|
+
name: entry.resolver.name,
|
|
7212
|
+
priority: entry.resolver.priority,
|
|
7213
|
+
keyPattern: entry.resolver.keyPattern,
|
|
7214
|
+
registeredBy: entry.registeredBy
|
|
7215
|
+
});
|
|
7216
|
+
}
|
|
7217
|
+
} else {
|
|
7218
|
+
for (const [map, entries] of this.resolvers.entries()) {
|
|
7219
|
+
for (const entry of entries) {
|
|
7220
|
+
result.push({
|
|
7221
|
+
mapName: map,
|
|
7222
|
+
name: entry.resolver.name,
|
|
7223
|
+
priority: entry.resolver.priority,
|
|
7224
|
+
keyPattern: entry.resolver.keyPattern,
|
|
7225
|
+
registeredBy: entry.registeredBy
|
|
7226
|
+
});
|
|
7227
|
+
}
|
|
7228
|
+
}
|
|
7229
|
+
}
|
|
7230
|
+
return result;
|
|
7231
|
+
}
|
|
7232
|
+
/**
|
|
7233
|
+
* Check if a map has any registered resolvers.
|
|
7234
|
+
*/
|
|
7235
|
+
hasResolvers(mapName) {
|
|
7236
|
+
const entries = this.resolvers.get(mapName);
|
|
7237
|
+
return entries !== void 0 && entries.length > 0;
|
|
7238
|
+
}
|
|
7239
|
+
/**
|
|
7240
|
+
* Get the number of registered resolvers.
|
|
7241
|
+
*/
|
|
7242
|
+
get size() {
|
|
7243
|
+
let count = 0;
|
|
7244
|
+
for (const entries of this.resolvers.values()) {
|
|
7245
|
+
count += entries.length;
|
|
7246
|
+
}
|
|
7247
|
+
return count;
|
|
7248
|
+
}
|
|
7249
|
+
/**
|
|
7250
|
+
* Clear all registered resolvers.
|
|
7251
|
+
*
|
|
7252
|
+
* @param mapName Optional - only clear resolvers for specific map
|
|
7253
|
+
*/
|
|
7254
|
+
clear(mapName) {
|
|
7255
|
+
if (mapName) {
|
|
7256
|
+
this.resolvers.delete(mapName);
|
|
7257
|
+
} else {
|
|
7258
|
+
this.resolvers.clear();
|
|
7259
|
+
}
|
|
7260
|
+
}
|
|
7261
|
+
/**
|
|
7262
|
+
* Clear resolvers registered by a specific client.
|
|
7263
|
+
*/
|
|
7264
|
+
clearByClient(clientId) {
|
|
7265
|
+
let removed = 0;
|
|
7266
|
+
for (const [mapName, entries] of this.resolvers.entries()) {
|
|
7267
|
+
const before = entries.length;
|
|
7268
|
+
const filtered = entries.filter((e) => e.registeredBy !== clientId);
|
|
7269
|
+
removed += before - filtered.length;
|
|
7270
|
+
if (filtered.length === 0) {
|
|
7271
|
+
this.resolvers.delete(mapName);
|
|
7272
|
+
} else if (filtered.length !== before) {
|
|
7273
|
+
this.resolvers.set(mapName, filtered);
|
|
7274
|
+
}
|
|
7275
|
+
}
|
|
7276
|
+
return removed;
|
|
7277
|
+
}
|
|
7278
|
+
/**
|
|
7279
|
+
* Dispose the service.
|
|
7280
|
+
*/
|
|
7281
|
+
dispose() {
|
|
7282
|
+
if (this.disposed) return;
|
|
7283
|
+
this.disposed = true;
|
|
7284
|
+
this.resolvers.clear();
|
|
7285
|
+
logger.debug("ConflictResolverService disposed");
|
|
7286
|
+
}
|
|
7287
|
+
/**
|
|
7288
|
+
* Match a key against a glob-like pattern.
|
|
7289
|
+
* Supports * (any chars) and ? (single char).
|
|
7290
|
+
*/
|
|
7291
|
+
matchKeyPattern(key, pattern) {
|
|
7292
|
+
const regexPattern = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
7293
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
7294
|
+
return regex.test(key);
|
|
7295
|
+
}
|
|
7296
|
+
/**
|
|
7297
|
+
* Compile sandboxed resolver code.
|
|
7298
|
+
*/
|
|
7299
|
+
compileSandboxed(name, code) {
|
|
7300
|
+
return async (ctx) => {
|
|
7301
|
+
const wrappedCode = `
|
|
7302
|
+
const context = {
|
|
7303
|
+
mapName: ${JSON.stringify(ctx.mapName)},
|
|
7304
|
+
key: ${JSON.stringify(ctx.key)},
|
|
7305
|
+
localValue: ${JSON.stringify(ctx.localValue)},
|
|
7306
|
+
remoteValue: ${JSON.stringify(ctx.remoteValue)},
|
|
7307
|
+
localTimestamp: ${JSON.stringify(ctx.localTimestamp)},
|
|
7308
|
+
remoteTimestamp: ${JSON.stringify(ctx.remoteTimestamp)},
|
|
7309
|
+
remoteNodeId: ${JSON.stringify(ctx.remoteNodeId)},
|
|
7310
|
+
auth: ${JSON.stringify(ctx.auth)},
|
|
7311
|
+
};
|
|
7312
|
+
|
|
7313
|
+
function resolve(context) {
|
|
7314
|
+
${code}
|
|
7315
|
+
}
|
|
7316
|
+
|
|
7317
|
+
const result = resolve(context);
|
|
7318
|
+
return { value: result, result };
|
|
7319
|
+
`;
|
|
7320
|
+
const result = await this.sandbox.execute(
|
|
7321
|
+
{
|
|
7322
|
+
name: `resolver:${name}`,
|
|
7323
|
+
code: wrappedCode
|
|
7324
|
+
},
|
|
7325
|
+
null,
|
|
7326
|
+
// value parameter unused for resolvers
|
|
7327
|
+
"resolver"
|
|
7328
|
+
);
|
|
7329
|
+
if (!result.success) {
|
|
7330
|
+
throw new Error(result.error || "Resolver execution failed");
|
|
7331
|
+
}
|
|
7332
|
+
const resolverResult = result.result;
|
|
7333
|
+
if (!resolverResult || typeof resolverResult !== "object") {
|
|
7334
|
+
throw new Error("Resolver must return a result object");
|
|
7335
|
+
}
|
|
7336
|
+
const action = resolverResult.action;
|
|
7337
|
+
if (!["accept", "reject", "merge", "local"].includes(action)) {
|
|
7338
|
+
throw new Error(`Invalid resolver action: ${action}`);
|
|
7339
|
+
}
|
|
7340
|
+
return resolverResult;
|
|
7341
|
+
};
|
|
7342
|
+
}
|
|
7343
|
+
};
|
|
7344
|
+
|
|
7345
|
+
// src/handlers/ConflictResolverHandler.ts
|
|
7346
|
+
var ConflictResolverHandler = class {
|
|
7347
|
+
constructor(config) {
|
|
7348
|
+
this.rejectionListeners = /* @__PURE__ */ new Set();
|
|
7349
|
+
this.nodeId = config.nodeId;
|
|
7350
|
+
this.sandbox = new ProcessorSandbox(config.sandboxConfig);
|
|
7351
|
+
this.resolverService = new ConflictResolverService(
|
|
7352
|
+
this.sandbox,
|
|
7353
|
+
config.resolverConfig
|
|
7354
|
+
);
|
|
7355
|
+
this.resolverService.onRejection((rejection) => {
|
|
7356
|
+
for (const listener of this.rejectionListeners) {
|
|
7357
|
+
try {
|
|
7358
|
+
listener(rejection);
|
|
7359
|
+
} catch (e) {
|
|
7360
|
+
logger.error({ error: e }, "Error in rejection listener");
|
|
7361
|
+
}
|
|
7362
|
+
}
|
|
7363
|
+
});
|
|
7364
|
+
}
|
|
7365
|
+
/**
|
|
7366
|
+
* Register a conflict resolver for a map.
|
|
7367
|
+
*
|
|
7368
|
+
* @param mapName The map name
|
|
7369
|
+
* @param resolver The resolver definition
|
|
7370
|
+
* @param clientId Optional client ID that registered this resolver
|
|
7371
|
+
*/
|
|
7372
|
+
registerResolver(mapName, resolver, clientId) {
|
|
7373
|
+
this.resolverService.register(mapName, resolver, clientId);
|
|
7374
|
+
logger.info(
|
|
7375
|
+
{
|
|
7376
|
+
mapName,
|
|
7377
|
+
resolverName: resolver.name,
|
|
7378
|
+
priority: resolver.priority,
|
|
7379
|
+
clientId
|
|
7380
|
+
},
|
|
7381
|
+
"Resolver registered"
|
|
7382
|
+
);
|
|
7383
|
+
}
|
|
7384
|
+
/**
|
|
7385
|
+
* Unregister a conflict resolver.
|
|
7386
|
+
*
|
|
7387
|
+
* @param mapName The map name
|
|
7388
|
+
* @param resolverName The resolver name
|
|
7389
|
+
* @param clientId Optional - only unregister if registered by this client
|
|
7390
|
+
*/
|
|
7391
|
+
unregisterResolver(mapName, resolverName, clientId) {
|
|
7392
|
+
const removed = this.resolverService.unregister(
|
|
7393
|
+
mapName,
|
|
7394
|
+
resolverName,
|
|
7395
|
+
clientId
|
|
7396
|
+
);
|
|
7397
|
+
if (removed) {
|
|
7398
|
+
logger.info({ mapName, resolverName, clientId }, "Resolver unregistered");
|
|
7399
|
+
}
|
|
7400
|
+
return removed;
|
|
7401
|
+
}
|
|
7402
|
+
/**
|
|
7403
|
+
* List registered resolvers.
|
|
7404
|
+
*
|
|
7405
|
+
* @param mapName Optional - filter by map name
|
|
7406
|
+
*/
|
|
7407
|
+
listResolvers(mapName) {
|
|
7408
|
+
return this.resolverService.list(mapName);
|
|
7409
|
+
}
|
|
7410
|
+
/**
|
|
7411
|
+
* Apply a merge with conflict resolution.
|
|
7412
|
+
*
|
|
7413
|
+
* Deletions (tombstones) are also passed through resolvers to allow
|
|
7414
|
+
* protection via IMMUTABLE, OWNER_ONLY, or similar resolvers.
|
|
7415
|
+
* If no custom resolvers are registered, deletions use standard LWW.
|
|
7416
|
+
*
|
|
7417
|
+
* @param map The LWWMap to merge into
|
|
7418
|
+
* @param mapName The map name (for resolver lookup)
|
|
7419
|
+
* @param key The key being merged
|
|
7420
|
+
* @param record The incoming record
|
|
7421
|
+
* @param remoteNodeId The source node ID
|
|
7422
|
+
* @param auth Optional authentication context
|
|
7423
|
+
*/
|
|
7424
|
+
async mergeWithResolver(map, mapName, key, record, remoteNodeId, auth) {
|
|
7425
|
+
const isDeletion = record.value === null;
|
|
7426
|
+
const localRecord = map.getRecord(key);
|
|
7427
|
+
const context = {
|
|
7428
|
+
mapName,
|
|
7429
|
+
key,
|
|
7430
|
+
localValue: localRecord?.value ?? void 0,
|
|
7431
|
+
// For deletions, remoteValue is null - resolvers can check this
|
|
7432
|
+
remoteValue: record.value,
|
|
7433
|
+
localTimestamp: localRecord?.timestamp,
|
|
7434
|
+
remoteTimestamp: record.timestamp,
|
|
7435
|
+
remoteNodeId,
|
|
7436
|
+
auth,
|
|
7437
|
+
readEntry: (k) => map.get(k)
|
|
7438
|
+
};
|
|
7439
|
+
const result = await this.resolverService.resolve(context);
|
|
7440
|
+
switch (result.action) {
|
|
7441
|
+
case "accept":
|
|
7442
|
+
case "merge": {
|
|
7443
|
+
const finalValue = isDeletion ? null : result.value;
|
|
7444
|
+
const finalRecord = {
|
|
7445
|
+
value: finalValue,
|
|
7446
|
+
timestamp: record.timestamp,
|
|
7447
|
+
ttlMs: record.ttlMs
|
|
7448
|
+
};
|
|
7449
|
+
map.merge(key, finalRecord);
|
|
7450
|
+
return { applied: true, result, record: finalRecord };
|
|
7451
|
+
}
|
|
7452
|
+
case "reject": {
|
|
7453
|
+
const rejection = {
|
|
7454
|
+
mapName,
|
|
7455
|
+
key,
|
|
7456
|
+
attemptedValue: record.value,
|
|
7457
|
+
reason: result.reason,
|
|
7458
|
+
timestamp: record.timestamp,
|
|
7459
|
+
nodeId: remoteNodeId
|
|
7460
|
+
};
|
|
7461
|
+
return { applied: false, result, rejection };
|
|
7462
|
+
}
|
|
7463
|
+
case "local":
|
|
7464
|
+
default:
|
|
7465
|
+
return { applied: false, result };
|
|
7466
|
+
}
|
|
7467
|
+
}
|
|
7468
|
+
/**
|
|
7469
|
+
* Check if a map has custom resolvers registered.
|
|
7470
|
+
*/
|
|
7471
|
+
hasResolvers(mapName) {
|
|
7472
|
+
return this.resolverService.hasResolvers(mapName);
|
|
7473
|
+
}
|
|
7474
|
+
/**
|
|
7475
|
+
* Add a listener for merge rejections.
|
|
7476
|
+
*/
|
|
7477
|
+
onRejection(listener) {
|
|
7478
|
+
this.rejectionListeners.add(listener);
|
|
7479
|
+
return () => this.rejectionListeners.delete(listener);
|
|
7480
|
+
}
|
|
7481
|
+
/**
|
|
7482
|
+
* Clear resolvers registered by a specific client.
|
|
7483
|
+
*/
|
|
7484
|
+
clearByClient(clientId) {
|
|
7485
|
+
return this.resolverService.clearByClient(clientId);
|
|
7486
|
+
}
|
|
7487
|
+
/**
|
|
7488
|
+
* Get the number of registered resolvers.
|
|
7489
|
+
*/
|
|
7490
|
+
get resolverCount() {
|
|
7491
|
+
return this.resolverService.size;
|
|
7492
|
+
}
|
|
7493
|
+
/**
|
|
7494
|
+
* Check if sandbox is in secure mode.
|
|
7495
|
+
*/
|
|
7496
|
+
isSecureMode() {
|
|
7497
|
+
return this.sandbox.isSecureMode();
|
|
7498
|
+
}
|
|
7499
|
+
/**
|
|
7500
|
+
* Dispose of the handler.
|
|
7501
|
+
*/
|
|
7502
|
+
dispose() {
|
|
7503
|
+
this.resolverService.dispose();
|
|
7504
|
+
this.sandbox.dispose();
|
|
7505
|
+
this.rejectionListeners.clear();
|
|
7506
|
+
logger.debug("ConflictResolverHandler disposed");
|
|
7507
|
+
}
|
|
7508
|
+
};
|
|
7509
|
+
|
|
7510
|
+
// src/EventJournalService.ts
|
|
7511
|
+
var import_core14 = require("@topgunbuild/core");
|
|
7512
|
+
var DEFAULT_JOURNAL_SERVICE_CONFIG = {
|
|
7513
|
+
...import_core14.DEFAULT_EVENT_JOURNAL_CONFIG,
|
|
7514
|
+
tableName: "event_journal",
|
|
7515
|
+
persistBatchSize: 100,
|
|
7516
|
+
persistIntervalMs: 1e3
|
|
7517
|
+
};
|
|
7518
|
+
var TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
7519
|
+
function validateTableName(name) {
|
|
7520
|
+
if (!TABLE_NAME_REGEX.test(name)) {
|
|
7521
|
+
throw new Error(
|
|
7522
|
+
`Invalid table name "${name}". Table name must start with a letter or underscore and contain only alphanumeric characters and underscores.`
|
|
7523
|
+
);
|
|
7524
|
+
}
|
|
7525
|
+
}
|
|
7526
|
+
var EventJournalService = class extends import_core14.EventJournalImpl {
|
|
7527
|
+
constructor(config) {
|
|
7528
|
+
super(config);
|
|
7529
|
+
this.pendingPersist = [];
|
|
7530
|
+
this.isPersisting = false;
|
|
7531
|
+
this.isInitialized = false;
|
|
7532
|
+
this.isLoadingFromStorage = false;
|
|
7533
|
+
this.pool = config.pool;
|
|
7534
|
+
this.tableName = config.tableName ?? DEFAULT_JOURNAL_SERVICE_CONFIG.tableName;
|
|
7535
|
+
this.persistBatchSize = config.persistBatchSize ?? DEFAULT_JOURNAL_SERVICE_CONFIG.persistBatchSize;
|
|
7536
|
+
this.persistIntervalMs = config.persistIntervalMs ?? DEFAULT_JOURNAL_SERVICE_CONFIG.persistIntervalMs;
|
|
7537
|
+
validateTableName(this.tableName);
|
|
7538
|
+
this.subscribe((event) => {
|
|
7539
|
+
if (this.isLoadingFromStorage) return;
|
|
7540
|
+
if (event.sequence >= 0n && this.getConfig().persistent) {
|
|
7541
|
+
this.pendingPersist.push(event);
|
|
7542
|
+
if (this.pendingPersist.length >= this.persistBatchSize) {
|
|
7543
|
+
this.persistToStorage().catch((err) => {
|
|
7544
|
+
logger.error({ err }, "Failed to persist journal events");
|
|
7545
|
+
});
|
|
7546
|
+
}
|
|
7547
|
+
}
|
|
7548
|
+
});
|
|
7549
|
+
this.startPersistTimer();
|
|
7550
|
+
}
|
|
7551
|
+
/**
|
|
7552
|
+
* Initialize the journal service, creating table if needed.
|
|
7553
|
+
*/
|
|
7554
|
+
async initialize() {
|
|
7555
|
+
if (this.isInitialized) return;
|
|
7556
|
+
const client = await this.pool.connect();
|
|
7557
|
+
try {
|
|
7558
|
+
await client.query(`
|
|
7559
|
+
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
7560
|
+
sequence BIGINT PRIMARY KEY,
|
|
7561
|
+
type VARCHAR(10) NOT NULL CHECK (type IN ('PUT', 'UPDATE', 'DELETE')),
|
|
7562
|
+
map_name VARCHAR(255) NOT NULL,
|
|
7563
|
+
key VARCHAR(1024) NOT NULL,
|
|
7564
|
+
value JSONB,
|
|
7565
|
+
previous_value JSONB,
|
|
7566
|
+
timestamp JSONB NOT NULL,
|
|
7567
|
+
node_id VARCHAR(64) NOT NULL,
|
|
7568
|
+
metadata JSONB,
|
|
7569
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
7570
|
+
);
|
|
7571
|
+
`);
|
|
7572
|
+
await client.query(`
|
|
7573
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_map_name
|
|
7574
|
+
ON ${this.tableName}(map_name);
|
|
7575
|
+
`);
|
|
7576
|
+
await client.query(`
|
|
7577
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_key
|
|
7578
|
+
ON ${this.tableName}(map_name, key);
|
|
7579
|
+
`);
|
|
7580
|
+
await client.query(`
|
|
7581
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_created_at
|
|
7582
|
+
ON ${this.tableName}(created_at);
|
|
7583
|
+
`);
|
|
7584
|
+
await client.query(`
|
|
7585
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_node_id
|
|
7586
|
+
ON ${this.tableName}(node_id);
|
|
7587
|
+
`);
|
|
7588
|
+
this.isInitialized = true;
|
|
7589
|
+
logger.info({ tableName: this.tableName }, "EventJournalService initialized");
|
|
7590
|
+
} finally {
|
|
7591
|
+
client.release();
|
|
7592
|
+
}
|
|
7593
|
+
}
|
|
7594
|
+
/**
|
|
7595
|
+
* Persist pending events to PostgreSQL.
|
|
7596
|
+
*/
|
|
7597
|
+
async persistToStorage() {
|
|
7598
|
+
if (this.pendingPersist.length === 0 || this.isPersisting) return;
|
|
7599
|
+
this.isPersisting = true;
|
|
7600
|
+
const batch = this.pendingPersist.splice(0, this.persistBatchSize);
|
|
7601
|
+
try {
|
|
7602
|
+
if (batch.length === 0) return;
|
|
7603
|
+
const values = [];
|
|
7604
|
+
const placeholders = [];
|
|
7605
|
+
batch.forEach((e, i) => {
|
|
7606
|
+
const offset = i * 9;
|
|
7607
|
+
placeholders.push(
|
|
7608
|
+
`($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6}, $${offset + 7}, $${offset + 8}, $${offset + 9})`
|
|
7609
|
+
);
|
|
7610
|
+
values.push(
|
|
7611
|
+
e.sequence.toString(),
|
|
7612
|
+
e.type,
|
|
7613
|
+
e.mapName,
|
|
7614
|
+
e.key,
|
|
7615
|
+
e.value !== void 0 ? JSON.stringify(e.value) : null,
|
|
7616
|
+
e.previousValue !== void 0 ? JSON.stringify(e.previousValue) : null,
|
|
7617
|
+
JSON.stringify(e.timestamp),
|
|
7618
|
+
e.nodeId,
|
|
7619
|
+
e.metadata ? JSON.stringify(e.metadata) : null
|
|
7620
|
+
);
|
|
7621
|
+
});
|
|
7622
|
+
await this.pool.query(
|
|
7623
|
+
`INSERT INTO ${this.tableName}
|
|
7624
|
+
(sequence, type, map_name, key, value, previous_value, timestamp, node_id, metadata)
|
|
7625
|
+
VALUES ${placeholders.join(", ")}
|
|
7626
|
+
ON CONFLICT (sequence) DO NOTHING`,
|
|
7627
|
+
values
|
|
7628
|
+
);
|
|
7629
|
+
logger.debug({ count: batch.length }, "Persisted journal events");
|
|
7630
|
+
} catch (error) {
|
|
7631
|
+
this.pendingPersist.unshift(...batch);
|
|
7632
|
+
throw error;
|
|
7633
|
+
} finally {
|
|
7634
|
+
this.isPersisting = false;
|
|
7635
|
+
}
|
|
7636
|
+
}
|
|
7637
|
+
/**
|
|
7638
|
+
* Load journal events from PostgreSQL on startup.
|
|
7639
|
+
*/
|
|
7640
|
+
async loadFromStorage() {
|
|
7641
|
+
const config = this.getConfig();
|
|
7642
|
+
const result = await this.pool.query(
|
|
7643
|
+
`SELECT sequence, type, map_name, key, value, previous_value, timestamp, node_id, metadata
|
|
7644
|
+
FROM ${this.tableName}
|
|
7645
|
+
ORDER BY sequence DESC
|
|
7646
|
+
LIMIT $1`,
|
|
7647
|
+
[config.capacity]
|
|
7648
|
+
);
|
|
7649
|
+
const events = result.rows.reverse();
|
|
7650
|
+
this.isLoadingFromStorage = true;
|
|
7651
|
+
try {
|
|
7652
|
+
for (const row of events) {
|
|
7653
|
+
this.append({
|
|
7654
|
+
type: row.type,
|
|
7655
|
+
mapName: row.map_name,
|
|
7656
|
+
key: row.key,
|
|
7657
|
+
value: row.value,
|
|
7658
|
+
previousValue: row.previous_value,
|
|
7659
|
+
timestamp: typeof row.timestamp === "string" ? JSON.parse(row.timestamp) : row.timestamp,
|
|
7660
|
+
nodeId: row.node_id,
|
|
7661
|
+
metadata: row.metadata
|
|
7662
|
+
});
|
|
7663
|
+
}
|
|
7664
|
+
} finally {
|
|
7665
|
+
this.isLoadingFromStorage = false;
|
|
7666
|
+
}
|
|
7667
|
+
logger.info({ count: events.length }, "Loaded journal events from storage");
|
|
7668
|
+
}
|
|
7669
|
+
/**
|
|
7670
|
+
* Export events as NDJSON stream.
|
|
7671
|
+
*/
|
|
7672
|
+
exportStream(options = {}) {
|
|
7673
|
+
const self = this;
|
|
7674
|
+
return new ReadableStream({
|
|
7675
|
+
start(controller) {
|
|
7676
|
+
const startSeq = options.fromSequence ?? self.getOldestSequence();
|
|
7677
|
+
const endSeq = options.toSequence ?? self.getLatestSequence();
|
|
7678
|
+
for (let seq = startSeq; seq <= endSeq; seq++) {
|
|
7679
|
+
const events = self.readFrom(seq, 1);
|
|
7680
|
+
if (events.length > 0) {
|
|
7681
|
+
const event = events[0];
|
|
7682
|
+
if (options.mapName && event.mapName !== options.mapName) continue;
|
|
7683
|
+
if (options.types && !options.types.includes(event.type)) continue;
|
|
7684
|
+
const serializable = {
|
|
7685
|
+
...event,
|
|
7686
|
+
sequence: event.sequence.toString()
|
|
7687
|
+
};
|
|
7688
|
+
controller.enqueue(JSON.stringify(serializable) + "\n");
|
|
7689
|
+
}
|
|
7690
|
+
}
|
|
7691
|
+
controller.close();
|
|
7692
|
+
}
|
|
7693
|
+
});
|
|
7694
|
+
}
|
|
7695
|
+
/**
|
|
7696
|
+
* Get events for a specific map.
|
|
7697
|
+
*/
|
|
7698
|
+
getMapEvents(mapName, fromSeq) {
|
|
7699
|
+
const events = this.readFrom(fromSeq ?? this.getOldestSequence(), this.getConfig().capacity);
|
|
7700
|
+
return events.filter((e) => e.mapName === mapName);
|
|
7701
|
+
}
|
|
7702
|
+
/**
|
|
7703
|
+
* Query events from PostgreSQL with filters.
|
|
7704
|
+
*/
|
|
7705
|
+
async queryFromStorage(options = {}) {
|
|
7706
|
+
const conditions = [];
|
|
7707
|
+
const params = [];
|
|
7708
|
+
let paramIndex = 1;
|
|
7709
|
+
if (options.mapName) {
|
|
7710
|
+
conditions.push(`map_name = $${paramIndex++}`);
|
|
7711
|
+
params.push(options.mapName);
|
|
7712
|
+
}
|
|
7713
|
+
if (options.key) {
|
|
7714
|
+
conditions.push(`key = $${paramIndex++}`);
|
|
7715
|
+
params.push(options.key);
|
|
7716
|
+
}
|
|
7717
|
+
if (options.types && options.types.length > 0) {
|
|
7718
|
+
conditions.push(`type = ANY($${paramIndex++})`);
|
|
7719
|
+
params.push(options.types);
|
|
7720
|
+
}
|
|
7721
|
+
if (options.fromSequence !== void 0) {
|
|
7722
|
+
conditions.push(`sequence >= $${paramIndex++}`);
|
|
7723
|
+
params.push(options.fromSequence.toString());
|
|
7724
|
+
}
|
|
7725
|
+
if (options.toSequence !== void 0) {
|
|
7726
|
+
conditions.push(`sequence <= $${paramIndex++}`);
|
|
7727
|
+
params.push(options.toSequence.toString());
|
|
7728
|
+
}
|
|
7729
|
+
if (options.fromDate) {
|
|
7730
|
+
conditions.push(`created_at >= $${paramIndex++}`);
|
|
7731
|
+
params.push(options.fromDate);
|
|
7732
|
+
}
|
|
7733
|
+
if (options.toDate) {
|
|
7734
|
+
conditions.push(`created_at <= $${paramIndex++}`);
|
|
7735
|
+
params.push(options.toDate);
|
|
7736
|
+
}
|
|
7737
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
7738
|
+
const limit = options.limit ?? 100;
|
|
7739
|
+
const offset = options.offset ?? 0;
|
|
7740
|
+
const result = await this.pool.query(
|
|
7741
|
+
`SELECT sequence, type, map_name, key, value, previous_value, timestamp, node_id, metadata
|
|
7742
|
+
FROM ${this.tableName}
|
|
7743
|
+
${whereClause}
|
|
7744
|
+
ORDER BY sequence ASC
|
|
7745
|
+
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
|
7746
|
+
[...params, limit, offset]
|
|
7747
|
+
);
|
|
7748
|
+
return result.rows.map((row) => ({
|
|
7749
|
+
sequence: BigInt(row.sequence),
|
|
7750
|
+
type: row.type,
|
|
7751
|
+
mapName: row.map_name,
|
|
7752
|
+
key: row.key,
|
|
7753
|
+
value: row.value,
|
|
7754
|
+
previousValue: row.previous_value,
|
|
7755
|
+
timestamp: typeof row.timestamp === "string" ? JSON.parse(row.timestamp) : row.timestamp,
|
|
7756
|
+
nodeId: row.node_id,
|
|
7757
|
+
metadata: row.metadata
|
|
7758
|
+
}));
|
|
7759
|
+
}
|
|
7760
|
+
/**
|
|
7761
|
+
* Count events matching filters.
|
|
7762
|
+
*/
|
|
7763
|
+
async countFromStorage(options = {}) {
|
|
7764
|
+
const conditions = [];
|
|
7765
|
+
const params = [];
|
|
7766
|
+
let paramIndex = 1;
|
|
7767
|
+
if (options.mapName) {
|
|
7768
|
+
conditions.push(`map_name = $${paramIndex++}`);
|
|
7769
|
+
params.push(options.mapName);
|
|
7770
|
+
}
|
|
7771
|
+
if (options.types && options.types.length > 0) {
|
|
7772
|
+
conditions.push(`type = ANY($${paramIndex++})`);
|
|
7773
|
+
params.push(options.types);
|
|
7774
|
+
}
|
|
7775
|
+
if (options.fromDate) {
|
|
7776
|
+
conditions.push(`created_at >= $${paramIndex++}`);
|
|
7777
|
+
params.push(options.fromDate);
|
|
7778
|
+
}
|
|
7779
|
+
if (options.toDate) {
|
|
7780
|
+
conditions.push(`created_at <= $${paramIndex++}`);
|
|
7781
|
+
params.push(options.toDate);
|
|
7782
|
+
}
|
|
7783
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
7784
|
+
const result = await this.pool.query(
|
|
7785
|
+
`SELECT COUNT(*) as count FROM ${this.tableName} ${whereClause}`,
|
|
7786
|
+
params
|
|
7787
|
+
);
|
|
7788
|
+
return parseInt(result.rows[0].count, 10);
|
|
7789
|
+
}
|
|
7790
|
+
/**
|
|
7791
|
+
* Cleanup old events based on retention policy.
|
|
7792
|
+
*/
|
|
7793
|
+
async cleanupOldEvents(retentionDays) {
|
|
7794
|
+
const result = await this.pool.query(
|
|
7795
|
+
`DELETE FROM ${this.tableName}
|
|
7796
|
+
WHERE created_at < NOW() - ($1 || ' days')::INTERVAL
|
|
7797
|
+
RETURNING sequence`,
|
|
7798
|
+
[retentionDays]
|
|
7799
|
+
);
|
|
7800
|
+
const count = result.rowCount ?? 0;
|
|
7801
|
+
if (count > 0) {
|
|
7802
|
+
logger.info({ deletedCount: count, retentionDays }, "Cleaned up old journal events");
|
|
7803
|
+
}
|
|
7804
|
+
return count;
|
|
7805
|
+
}
|
|
7806
|
+
/**
|
|
7807
|
+
* Start the periodic persistence timer.
|
|
7808
|
+
*/
|
|
7809
|
+
startPersistTimer() {
|
|
7810
|
+
this.persistTimer = setInterval(() => {
|
|
7811
|
+
if (this.pendingPersist.length > 0) {
|
|
7812
|
+
this.persistToStorage().catch((err) => {
|
|
7813
|
+
logger.error({ err }, "Periodic persist failed");
|
|
7814
|
+
});
|
|
7815
|
+
}
|
|
7816
|
+
}, this.persistIntervalMs);
|
|
7817
|
+
}
|
|
7818
|
+
/**
|
|
7819
|
+
* Stop the periodic persistence timer.
|
|
7820
|
+
*/
|
|
7821
|
+
stopPersistTimer() {
|
|
7822
|
+
if (this.persistTimer) {
|
|
7823
|
+
clearInterval(this.persistTimer);
|
|
7824
|
+
this.persistTimer = void 0;
|
|
7825
|
+
}
|
|
7826
|
+
}
|
|
7827
|
+
/**
|
|
7828
|
+
* Dispose resources and persist remaining events.
|
|
7829
|
+
*/
|
|
7830
|
+
dispose() {
|
|
7831
|
+
this.stopPersistTimer();
|
|
7832
|
+
if (this.pendingPersist.length > 0) {
|
|
7833
|
+
this.persistToStorage().catch((err) => {
|
|
7834
|
+
logger.error({ err }, "Final persist failed on dispose");
|
|
7835
|
+
});
|
|
7836
|
+
}
|
|
7837
|
+
super.dispose();
|
|
7838
|
+
}
|
|
7839
|
+
/**
|
|
7840
|
+
* Get pending persist count (for monitoring).
|
|
7841
|
+
*/
|
|
7842
|
+
getPendingPersistCount() {
|
|
7843
|
+
return this.pendingPersist.length;
|
|
7844
|
+
}
|
|
7845
|
+
};
|
|
7846
|
+
|
|
7847
|
+
// src/ServerCoordinator.ts
|
|
7848
|
+
var GC_INTERVAL_MS = 60 * 60 * 1e3;
|
|
7849
|
+
var GC_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
7850
|
+
var CLIENT_HEARTBEAT_TIMEOUT_MS = 2e4;
|
|
7851
|
+
var CLIENT_HEARTBEAT_CHECK_INTERVAL_MS = 5e3;
|
|
7852
|
+
var ServerCoordinator = class {
|
|
7853
|
+
constructor(config) {
|
|
7854
|
+
this.clients = /* @__PURE__ */ new Map();
|
|
7855
|
+
// Interceptors
|
|
7856
|
+
this.interceptors = [];
|
|
7857
|
+
// In-memory storage (partitioned later)
|
|
7858
|
+
this.maps = /* @__PURE__ */ new Map();
|
|
7859
|
+
this.pendingClusterQueries = /* @__PURE__ */ new Map();
|
|
7860
|
+
// GC Consensus State
|
|
7861
|
+
this.gcReports = /* @__PURE__ */ new Map();
|
|
7862
|
+
// Track map loading state to avoid returning empty results during async load
|
|
7863
|
+
this.mapLoadingPromises = /* @__PURE__ */ new Map();
|
|
7864
|
+
// Track pending batch operations for testing purposes
|
|
7865
|
+
this.pendingBatchOperations = /* @__PURE__ */ new Set();
|
|
7866
|
+
this.journalSubscriptions = /* @__PURE__ */ new Map();
|
|
7867
|
+
this._actualPort = 0;
|
|
7868
|
+
this._actualClusterPort = 0;
|
|
7869
|
+
this._readyPromise = new Promise((resolve) => {
|
|
7870
|
+
this._readyResolve = resolve;
|
|
7871
|
+
});
|
|
7872
|
+
this._nodeId = config.nodeId;
|
|
7873
|
+
this.hlc = new import_core15.HLC(config.nodeId);
|
|
7874
|
+
this.storage = config.storage;
|
|
7875
|
+
const rawSecret = config.jwtSecret || process.env.JWT_SECRET || "topgun-secret-dev";
|
|
7876
|
+
this.jwtSecret = rawSecret.replace(/\\n/g, "\n");
|
|
7877
|
+
this.queryRegistry = new QueryRegistry();
|
|
7878
|
+
this.securityManager = new SecurityManager(config.securityPolicies || []);
|
|
7879
|
+
this.interceptors = config.interceptors || [];
|
|
7880
|
+
this.metricsService = new MetricsService();
|
|
7881
|
+
this.eventExecutor = new StripedEventExecutor({
|
|
7882
|
+
stripeCount: config.eventStripeCount ?? 4,
|
|
7883
|
+
queueCapacity: config.eventQueueCapacity ?? 1e4,
|
|
7884
|
+
name: `${config.nodeId}-event-executor`,
|
|
7885
|
+
onReject: (task) => {
|
|
7886
|
+
logger.warn({ nodeId: config.nodeId, key: task.key }, "Event task rejected due to queue capacity");
|
|
7887
|
+
this.metricsService.incEventQueueRejected();
|
|
7888
|
+
}
|
|
7889
|
+
});
|
|
7890
|
+
this.backpressure = new BackpressureRegulator({
|
|
7891
|
+
syncFrequency: config.backpressureSyncFrequency ?? 100,
|
|
7892
|
+
maxPendingOps: config.backpressureMaxPending ?? 1e3,
|
|
7893
|
+
backoffTimeoutMs: config.backpressureBackoffMs ?? 5e3,
|
|
7894
|
+
enabled: config.backpressureEnabled ?? true
|
|
7895
|
+
});
|
|
7896
|
+
this.writeCoalescingEnabled = config.writeCoalescingEnabled ?? true;
|
|
7897
|
+
const preset = coalescingPresets[config.writeCoalescingPreset ?? "highThroughput"];
|
|
7898
|
+
this.writeCoalescingOptions = {
|
|
7899
|
+
maxBatchSize: config.writeCoalescingMaxBatch ?? preset.maxBatchSize,
|
|
7900
|
+
maxDelayMs: config.writeCoalescingMaxDelayMs ?? preset.maxDelayMs,
|
|
7901
|
+
maxBatchBytes: config.writeCoalescingMaxBytes ?? preset.maxBatchBytes
|
|
7902
|
+
};
|
|
7903
|
+
this.eventPayloadPool = createEventPayloadPool({
|
|
7904
|
+
maxSize: 4096,
|
|
7905
|
+
initialSize: 128
|
|
7906
|
+
});
|
|
7907
|
+
this.taskletScheduler = new TaskletScheduler({
|
|
7908
|
+
defaultTimeBudgetMs: 5,
|
|
7909
|
+
maxConcurrent: 20
|
|
7910
|
+
});
|
|
7911
|
+
this.writeAckManager = new WriteAckManager({
|
|
7912
|
+
defaultTimeout: config.writeAckTimeout ?? 5e3
|
|
7913
|
+
});
|
|
7914
|
+
this.rateLimitingEnabled = config.rateLimitingEnabled ?? true;
|
|
7915
|
+
this.rateLimiter = new ConnectionRateLimiter({
|
|
7916
|
+
maxConnectionsPerSecond: config.maxConnectionsPerSecond ?? 100,
|
|
7917
|
+
maxPendingConnections: config.maxPendingConnections ?? 1e3,
|
|
7918
|
+
cooldownMs: 1e3
|
|
7919
|
+
});
|
|
7920
|
+
if (config.workerPoolEnabled) {
|
|
7921
|
+
this.workerPool = new WorkerPool({
|
|
7922
|
+
minWorkers: config.workerPoolConfig?.minWorkers ?? 2,
|
|
7923
|
+
maxWorkers: config.workerPoolConfig?.maxWorkers,
|
|
7924
|
+
taskTimeout: config.workerPoolConfig?.taskTimeout ?? 5e3,
|
|
7925
|
+
idleTimeout: config.workerPoolConfig?.idleTimeout ?? 3e4,
|
|
7926
|
+
autoRestart: config.workerPoolConfig?.autoRestart ?? true
|
|
7927
|
+
});
|
|
7928
|
+
this.merkleWorker = new MerkleWorker(this.workerPool);
|
|
7929
|
+
this.crdtMergeWorker = new CRDTMergeWorker(this.workerPool);
|
|
7930
|
+
this.serializationWorker = new SerializationWorker(this.workerPool);
|
|
7931
|
+
logger.info({
|
|
7932
|
+
minWorkers: config.workerPoolConfig?.minWorkers ?? 2,
|
|
7933
|
+
maxWorkers: config.workerPoolConfig?.maxWorkers ?? "auto"
|
|
7934
|
+
}, "Worker pool initialized for CPU-bound operations");
|
|
7935
|
+
}
|
|
7936
|
+
if (config.tls?.enabled) {
|
|
7937
|
+
const tlsOptions = this.buildTLSOptions(config.tls);
|
|
7938
|
+
this.httpServer = (0, import_https.createServer)(tlsOptions, (_req, res) => {
|
|
7939
|
+
res.writeHead(200);
|
|
7940
|
+
res.end("TopGun Server Running (Secure)");
|
|
7941
|
+
});
|
|
7942
|
+
logger.info("TLS enabled for client connections");
|
|
7943
|
+
} else {
|
|
7944
|
+
this.httpServer = (0, import_http.createServer)((_req, res) => {
|
|
7945
|
+
res.writeHead(200);
|
|
7946
|
+
res.end("TopGun Server Running");
|
|
7947
|
+
});
|
|
7948
|
+
if (process.env.NODE_ENV === "production") {
|
|
7949
|
+
logger.warn("\u26A0\uFE0F TLS is disabled! Client connections are NOT encrypted.");
|
|
7950
|
+
}
|
|
7951
|
+
}
|
|
7952
|
+
const metricsPort = config.metricsPort !== void 0 ? config.metricsPort : 9090;
|
|
7953
|
+
this.metricsServer = (0, import_http.createServer)(async (req, res) => {
|
|
7954
|
+
if (req.url === "/metrics") {
|
|
7955
|
+
try {
|
|
7956
|
+
res.setHeader("Content-Type", this.metricsService.getContentType());
|
|
7957
|
+
res.end(await this.metricsService.getMetrics());
|
|
7958
|
+
} catch (err) {
|
|
7959
|
+
res.statusCode = 500;
|
|
7960
|
+
res.end("Internal Server Error");
|
|
7961
|
+
}
|
|
7962
|
+
} else {
|
|
7963
|
+
res.statusCode = 404;
|
|
7964
|
+
res.end();
|
|
7965
|
+
}
|
|
7966
|
+
});
|
|
7967
|
+
this.metricsServer.listen(metricsPort, () => {
|
|
7968
|
+
logger.info({ port: metricsPort }, "Metrics server listening");
|
|
7969
|
+
});
|
|
7970
|
+
this.metricsServer.on("error", (err) => {
|
|
7971
|
+
logger.error({ err, port: metricsPort }, "Metrics server failed to start");
|
|
7972
|
+
});
|
|
7973
|
+
this.wss = new import_ws3.WebSocketServer({
|
|
7974
|
+
server: this.httpServer,
|
|
7975
|
+
// Increase backlog for pending connections (default Linux is 128)
|
|
7976
|
+
backlog: config.wsBacklog ?? 511,
|
|
7977
|
+
// Disable per-message deflate by default (CPU overhead)
|
|
7978
|
+
perMessageDeflate: config.wsCompression ?? false,
|
|
7979
|
+
// Max payload size (64MB default)
|
|
7980
|
+
maxPayload: config.wsMaxPayload ?? 64 * 1024 * 1024,
|
|
7981
|
+
// Skip UTF-8 validation for binary messages (performance)
|
|
7982
|
+
skipUTF8Validation: true
|
|
7983
|
+
});
|
|
7984
|
+
this.wss.on("connection", (ws) => this.handleConnection(ws));
|
|
7985
|
+
this.httpServer.maxConnections = config.maxConnections ?? 1e4;
|
|
7986
|
+
this.httpServer.timeout = config.serverTimeout ?? 12e4;
|
|
7987
|
+
this.httpServer.keepAliveTimeout = config.keepAliveTimeout ?? 5e3;
|
|
7988
|
+
this.httpServer.headersTimeout = config.headersTimeout ?? 6e4;
|
|
7989
|
+
this.httpServer.on("connection", (socket) => {
|
|
7990
|
+
socket.setNoDelay(true);
|
|
7991
|
+
socket.setKeepAlive(true, 6e4);
|
|
7992
|
+
});
|
|
7993
|
+
this.httpServer.listen(config.port, () => {
|
|
7994
|
+
const addr = this.httpServer.address();
|
|
7995
|
+
this._actualPort = typeof addr === "object" && addr ? addr.port : config.port;
|
|
7996
|
+
logger.info({ port: this._actualPort }, "Server Coordinator listening");
|
|
7997
|
+
const clusterPort = config.clusterPort ?? 0;
|
|
7998
|
+
const peers = config.resolvePeers ? config.resolvePeers() : config.peers || [];
|
|
7999
|
+
this.cluster = new ClusterManager({
|
|
8000
|
+
nodeId: config.nodeId,
|
|
8001
|
+
host: config.host || "localhost",
|
|
8002
|
+
port: clusterPort,
|
|
8003
|
+
peers,
|
|
8004
|
+
discovery: config.discovery,
|
|
8005
|
+
serviceName: config.serviceName,
|
|
8006
|
+
discoveryInterval: config.discoveryInterval,
|
|
8007
|
+
tls: config.clusterTls
|
|
8008
|
+
});
|
|
8009
|
+
this.partitionService = new PartitionService(this.cluster);
|
|
8010
|
+
if (config.replicationEnabled !== false) {
|
|
8011
|
+
this.replicationPipeline = new ReplicationPipeline(
|
|
8012
|
+
this.cluster,
|
|
8013
|
+
this.partitionService,
|
|
8014
|
+
{
|
|
8015
|
+
...import_core15.DEFAULT_REPLICATION_CONFIG,
|
|
8016
|
+
defaultConsistency: config.defaultConsistency ?? import_core15.ConsistencyLevel.EVENTUAL,
|
|
8017
|
+
...config.replicationConfig
|
|
8018
|
+
}
|
|
8019
|
+
);
|
|
8020
|
+
this.replicationPipeline.setOperationApplier(this.applyReplicatedOperation.bind(this));
|
|
8021
|
+
logger.info({ nodeId: config.nodeId }, "ReplicationPipeline initialized");
|
|
8022
|
+
}
|
|
8023
|
+
this.partitionService.on("rebalanced", (partitionMap, changes) => {
|
|
8024
|
+
this.broadcastPartitionMap(partitionMap);
|
|
8025
|
+
});
|
|
8026
|
+
this.lockManager = new LockManager();
|
|
8027
|
+
this.lockManager.on("lockGranted", (evt) => this.handleLockGranted(evt));
|
|
8028
|
+
this.topicManager = new TopicManager({
|
|
8029
|
+
cluster: this.cluster,
|
|
8030
|
+
sendToClient: (clientId, message) => {
|
|
8031
|
+
const client = this.clients.get(clientId);
|
|
8032
|
+
if (client && client.socket.readyState === import_ws3.WebSocket.OPEN) {
|
|
8033
|
+
client.writer.write(message);
|
|
8034
|
+
}
|
|
8035
|
+
}
|
|
8036
|
+
});
|
|
8037
|
+
this.counterHandler = new CounterHandler(this._nodeId);
|
|
8038
|
+
this.entryProcessorHandler = new EntryProcessorHandler({ hlc: this.hlc });
|
|
8039
|
+
this.conflictResolverHandler = new ConflictResolverHandler({ nodeId: this._nodeId });
|
|
8040
|
+
this.conflictResolverHandler.onRejection((rejection) => {
|
|
8041
|
+
this.notifyMergeRejection(rejection);
|
|
8042
|
+
});
|
|
8043
|
+
if (config.eventJournalEnabled && this.storage && "pool" in this.storage) {
|
|
8044
|
+
const pool = this.storage.pool;
|
|
8045
|
+
this.eventJournalService = new EventJournalService({
|
|
8046
|
+
capacity: 1e4,
|
|
8047
|
+
ttlMs: 0,
|
|
8048
|
+
persistent: true,
|
|
8049
|
+
pool,
|
|
8050
|
+
...config.eventJournalConfig
|
|
8051
|
+
});
|
|
8052
|
+
this.eventJournalService.initialize().then(() => {
|
|
8053
|
+
logger.info("EventJournalService initialized");
|
|
8054
|
+
}).catch((err) => {
|
|
8055
|
+
logger.error({ err }, "Failed to initialize EventJournalService");
|
|
8056
|
+
});
|
|
8057
|
+
}
|
|
8058
|
+
this.systemManager = new SystemManager(
|
|
8059
|
+
this.cluster,
|
|
8060
|
+
this.metricsService,
|
|
8061
|
+
(name) => this.getMap(name)
|
|
8062
|
+
);
|
|
8063
|
+
this.setupClusterListeners();
|
|
8064
|
+
this.cluster.start().then((actualClusterPort) => {
|
|
8065
|
+
this._actualClusterPort = actualClusterPort;
|
|
8066
|
+
this.metricsService.setClusterMembers(this.cluster.getMembers().length);
|
|
8067
|
+
logger.info({ clusterPort: this._actualClusterPort }, "Cluster started");
|
|
8068
|
+
this.systemManager.start();
|
|
8069
|
+
this._readyResolve();
|
|
8070
|
+
}).catch((err) => {
|
|
8071
|
+
this._actualClusterPort = clusterPort;
|
|
8072
|
+
this.metricsService.setClusterMembers(this.cluster.getMembers().length);
|
|
8073
|
+
logger.info({ clusterPort: this._actualClusterPort }, "Cluster started (sync)");
|
|
8074
|
+
this.systemManager.start();
|
|
8075
|
+
this._readyResolve();
|
|
8076
|
+
});
|
|
8077
|
+
});
|
|
8078
|
+
if (this.storage) {
|
|
8079
|
+
this.storage.initialize().then(() => {
|
|
8080
|
+
logger.info("Storage adapter initialized");
|
|
8081
|
+
}).catch((err) => {
|
|
8082
|
+
logger.error({ err }, "Failed to initialize storage");
|
|
8083
|
+
});
|
|
8084
|
+
}
|
|
8085
|
+
this.startGarbageCollection();
|
|
8086
|
+
this.startHeartbeatCheck();
|
|
8087
|
+
}
|
|
8088
|
+
/** Wait for server to be fully ready (ports assigned) */
|
|
8089
|
+
ready() {
|
|
8090
|
+
return this._readyPromise;
|
|
8091
|
+
}
|
|
8092
|
+
/**
|
|
8093
|
+
* Wait for all pending batch operations to complete.
|
|
8094
|
+
* Useful for tests that need to verify state after OP_BATCH.
|
|
6696
8095
|
*/
|
|
6697
8096
|
async waitForPendingBatches() {
|
|
6698
8097
|
if (this.pendingBatchOperations.size === 0) return;
|
|
@@ -6761,7 +8160,7 @@ var ServerCoordinator = class {
|
|
|
6761
8160
|
this.metricsService.destroy();
|
|
6762
8161
|
this.wss.close();
|
|
6763
8162
|
logger.info(`Closing ${this.clients.size} client connections...`);
|
|
6764
|
-
const shutdownMsg = (0,
|
|
8163
|
+
const shutdownMsg = (0, import_core15.serialize)({ type: "SHUTDOWN_PENDING", retryAfter: 5e3 });
|
|
6765
8164
|
for (const client of this.clients.values()) {
|
|
6766
8165
|
try {
|
|
6767
8166
|
if (client.socket.readyState === import_ws3.WebSocket.OPEN) {
|
|
@@ -6815,6 +8214,10 @@ var ServerCoordinator = class {
|
|
|
6815
8214
|
this.eventPayloadPool.clear();
|
|
6816
8215
|
this.taskletScheduler.shutdown();
|
|
6817
8216
|
this.writeAckManager.shutdown();
|
|
8217
|
+
this.entryProcessorHandler.dispose();
|
|
8218
|
+
if (this.eventJournalService) {
|
|
8219
|
+
this.eventJournalService.dispose();
|
|
8220
|
+
}
|
|
6818
8221
|
logger.info("Server Coordinator shutdown complete.");
|
|
6819
8222
|
}
|
|
6820
8223
|
async handleConnection(ws) {
|
|
@@ -6882,7 +8285,7 @@ var ServerCoordinator = class {
|
|
|
6882
8285
|
buf = Buffer.from(message);
|
|
6883
8286
|
}
|
|
6884
8287
|
try {
|
|
6885
|
-
data = (0,
|
|
8288
|
+
data = (0, import_core15.deserialize)(buf);
|
|
6886
8289
|
} catch (e) {
|
|
6887
8290
|
try {
|
|
6888
8291
|
const text = Buffer.isBuffer(buf) ? buf.toString() : new TextDecoder().decode(buf);
|
|
@@ -6921,6 +8324,7 @@ var ServerCoordinator = class {
|
|
|
6921
8324
|
}
|
|
6922
8325
|
this.lockManager.handleClientDisconnect(clientId);
|
|
6923
8326
|
this.topicManager.unsubscribeAll(clientId);
|
|
8327
|
+
this.counterHandler.unsubscribeAll(clientId);
|
|
6924
8328
|
const members = this.cluster.getMembers();
|
|
6925
8329
|
for (const memberId of members) {
|
|
6926
8330
|
if (!this.cluster.isLocal(memberId)) {
|
|
@@ -6933,10 +8337,10 @@ var ServerCoordinator = class {
|
|
|
6933
8337
|
this.clients.delete(clientId);
|
|
6934
8338
|
this.metricsService.setConnectedClients(this.clients.size);
|
|
6935
8339
|
});
|
|
6936
|
-
ws.send((0,
|
|
8340
|
+
ws.send((0, import_core15.serialize)({ type: "AUTH_REQUIRED" }));
|
|
6937
8341
|
}
|
|
6938
8342
|
async handleMessage(client, rawMessage) {
|
|
6939
|
-
const parseResult =
|
|
8343
|
+
const parseResult = import_core15.MessageSchema.safeParse(rawMessage);
|
|
6940
8344
|
if (!parseResult.success) {
|
|
6941
8345
|
logger.error({ clientId: client.id, error: parseResult.error }, "Invalid message format from client");
|
|
6942
8346
|
client.writer.write({
|
|
@@ -7176,7 +8580,7 @@ var ServerCoordinator = class {
|
|
|
7176
8580
|
this.metricsService.incOp("GET", message.mapName);
|
|
7177
8581
|
try {
|
|
7178
8582
|
const mapForSync = await this.getMapAsync(message.mapName);
|
|
7179
|
-
if (mapForSync instanceof
|
|
8583
|
+
if (mapForSync instanceof import_core15.LWWMap) {
|
|
7180
8584
|
const tree = mapForSync.getMerkleTree();
|
|
7181
8585
|
const rootHash = tree.getRootHash();
|
|
7182
8586
|
client.writer.write({
|
|
@@ -7214,7 +8618,7 @@ var ServerCoordinator = class {
|
|
|
7214
8618
|
const { mapName, path } = message.payload;
|
|
7215
8619
|
try {
|
|
7216
8620
|
const mapForBucket = await this.getMapAsync(mapName);
|
|
7217
|
-
if (mapForBucket instanceof
|
|
8621
|
+
if (mapForBucket instanceof import_core15.LWWMap) {
|
|
7218
8622
|
const treeForBucket = mapForBucket.getMerkleTree();
|
|
7219
8623
|
const buckets = treeForBucket.getBuckets(path);
|
|
7220
8624
|
const node = treeForBucket.getNode(path);
|
|
@@ -7343,6 +8747,219 @@ var ServerCoordinator = class {
|
|
|
7343
8747
|
}
|
|
7344
8748
|
break;
|
|
7345
8749
|
}
|
|
8750
|
+
// ============ Phase 5.2: PN Counter Handlers ============
|
|
8751
|
+
case "COUNTER_REQUEST": {
|
|
8752
|
+
const { name } = message.payload;
|
|
8753
|
+
const response = this.counterHandler.handleCounterRequest(client.id, name);
|
|
8754
|
+
client.writer.write(response);
|
|
8755
|
+
logger.debug({ clientId: client.id, name }, "Counter request handled");
|
|
8756
|
+
break;
|
|
8757
|
+
}
|
|
8758
|
+
case "COUNTER_SYNC": {
|
|
8759
|
+
const { name, state } = message.payload;
|
|
8760
|
+
const result = this.counterHandler.handleCounterSync(client.id, name, state);
|
|
8761
|
+
client.writer.write(result.response);
|
|
8762
|
+
for (const targetClientId of result.broadcastTo) {
|
|
8763
|
+
const targetClient = this.clients.get(targetClientId);
|
|
8764
|
+
if (targetClient && targetClient.socket.readyState === import_ws3.WebSocket.OPEN) {
|
|
8765
|
+
targetClient.writer.write(result.broadcastMessage);
|
|
8766
|
+
}
|
|
8767
|
+
}
|
|
8768
|
+
logger.debug({ clientId: client.id, name, broadcastCount: result.broadcastTo.length }, "Counter sync handled");
|
|
8769
|
+
break;
|
|
8770
|
+
}
|
|
8771
|
+
// ============ Phase 5.03: Entry Processor Handlers ============
|
|
8772
|
+
case "ENTRY_PROCESS": {
|
|
8773
|
+
const { requestId, mapName, key, processor } = message;
|
|
8774
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
8775
|
+
client.writer.write({
|
|
8776
|
+
type: "ENTRY_PROCESS_RESPONSE",
|
|
8777
|
+
requestId,
|
|
8778
|
+
success: false,
|
|
8779
|
+
error: `Access Denied for map ${mapName}`
|
|
8780
|
+
}, true);
|
|
8781
|
+
break;
|
|
8782
|
+
}
|
|
8783
|
+
const entryMap = this.getMap(mapName);
|
|
8784
|
+
const { result, timestamp } = await this.entryProcessorHandler.executeOnKey(
|
|
8785
|
+
entryMap,
|
|
8786
|
+
key,
|
|
8787
|
+
processor
|
|
8788
|
+
);
|
|
8789
|
+
client.writer.write({
|
|
8790
|
+
type: "ENTRY_PROCESS_RESPONSE",
|
|
8791
|
+
requestId,
|
|
8792
|
+
success: result.success,
|
|
8793
|
+
result: result.result,
|
|
8794
|
+
newValue: result.newValue,
|
|
8795
|
+
error: result.error
|
|
8796
|
+
});
|
|
8797
|
+
if (result.success && timestamp) {
|
|
8798
|
+
const record = entryMap.getRecord(key);
|
|
8799
|
+
if (record) {
|
|
8800
|
+
this.queryRegistry.processChange(mapName, entryMap, key, record, void 0);
|
|
8801
|
+
}
|
|
8802
|
+
}
|
|
8803
|
+
logger.debug({
|
|
8804
|
+
clientId: client.id,
|
|
8805
|
+
mapName,
|
|
8806
|
+
key,
|
|
8807
|
+
processor: processor.name,
|
|
8808
|
+
success: result.success
|
|
8809
|
+
}, "Entry processor executed");
|
|
8810
|
+
break;
|
|
8811
|
+
}
|
|
8812
|
+
case "ENTRY_PROCESS_BATCH": {
|
|
8813
|
+
const { requestId, mapName, keys, processor } = message;
|
|
8814
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
8815
|
+
const errorResults = {};
|
|
8816
|
+
for (const key of keys) {
|
|
8817
|
+
errorResults[key] = {
|
|
8818
|
+
success: false,
|
|
8819
|
+
error: `Access Denied for map ${mapName}`
|
|
8820
|
+
};
|
|
8821
|
+
}
|
|
8822
|
+
client.writer.write({
|
|
8823
|
+
type: "ENTRY_PROCESS_BATCH_RESPONSE",
|
|
8824
|
+
requestId,
|
|
8825
|
+
results: errorResults
|
|
8826
|
+
}, true);
|
|
8827
|
+
break;
|
|
8828
|
+
}
|
|
8829
|
+
const batchMap = this.getMap(mapName);
|
|
8830
|
+
const { results, timestamps } = await this.entryProcessorHandler.executeOnKeys(
|
|
8831
|
+
batchMap,
|
|
8832
|
+
keys,
|
|
8833
|
+
processor
|
|
8834
|
+
);
|
|
8835
|
+
const resultsRecord = {};
|
|
8836
|
+
for (const [key, keyResult] of results) {
|
|
8837
|
+
resultsRecord[key] = {
|
|
8838
|
+
success: keyResult.success,
|
|
8839
|
+
result: keyResult.result,
|
|
8840
|
+
newValue: keyResult.newValue,
|
|
8841
|
+
error: keyResult.error
|
|
8842
|
+
};
|
|
8843
|
+
}
|
|
8844
|
+
client.writer.write({
|
|
8845
|
+
type: "ENTRY_PROCESS_BATCH_RESPONSE",
|
|
8846
|
+
requestId,
|
|
8847
|
+
results: resultsRecord
|
|
8848
|
+
});
|
|
8849
|
+
for (const [key] of timestamps) {
|
|
8850
|
+
const record = batchMap.getRecord(key);
|
|
8851
|
+
if (record) {
|
|
8852
|
+
this.queryRegistry.processChange(mapName, batchMap, key, record, void 0);
|
|
8853
|
+
}
|
|
8854
|
+
}
|
|
8855
|
+
logger.debug({
|
|
8856
|
+
clientId: client.id,
|
|
8857
|
+
mapName,
|
|
8858
|
+
keyCount: keys.length,
|
|
8859
|
+
processor: processor.name,
|
|
8860
|
+
successCount: Array.from(results.values()).filter((r) => r.success).length
|
|
8861
|
+
}, "Entry processor batch executed");
|
|
8862
|
+
break;
|
|
8863
|
+
}
|
|
8864
|
+
// ============ Phase 5.05: Conflict Resolver Handlers ============
|
|
8865
|
+
case "REGISTER_RESOLVER": {
|
|
8866
|
+
const { requestId, mapName, resolver } = message;
|
|
8867
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
8868
|
+
client.writer.write({
|
|
8869
|
+
type: "REGISTER_RESOLVER_RESPONSE",
|
|
8870
|
+
requestId,
|
|
8871
|
+
success: false,
|
|
8872
|
+
error: `Access Denied for map ${mapName}`
|
|
8873
|
+
}, true);
|
|
8874
|
+
break;
|
|
8875
|
+
}
|
|
8876
|
+
try {
|
|
8877
|
+
this.conflictResolverHandler.registerResolver(
|
|
8878
|
+
mapName,
|
|
8879
|
+
{
|
|
8880
|
+
name: resolver.name,
|
|
8881
|
+
code: resolver.code,
|
|
8882
|
+
priority: resolver.priority,
|
|
8883
|
+
keyPattern: resolver.keyPattern
|
|
8884
|
+
},
|
|
8885
|
+
client.id
|
|
8886
|
+
);
|
|
8887
|
+
client.writer.write({
|
|
8888
|
+
type: "REGISTER_RESOLVER_RESPONSE",
|
|
8889
|
+
requestId,
|
|
8890
|
+
success: true
|
|
8891
|
+
});
|
|
8892
|
+
logger.info({
|
|
8893
|
+
clientId: client.id,
|
|
8894
|
+
mapName,
|
|
8895
|
+
resolverName: resolver.name,
|
|
8896
|
+
priority: resolver.priority
|
|
8897
|
+
}, "Conflict resolver registered");
|
|
8898
|
+
} catch (err) {
|
|
8899
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
8900
|
+
client.writer.write({
|
|
8901
|
+
type: "REGISTER_RESOLVER_RESPONSE",
|
|
8902
|
+
requestId,
|
|
8903
|
+
success: false,
|
|
8904
|
+
error: errorMessage
|
|
8905
|
+
}, true);
|
|
8906
|
+
logger.warn({
|
|
8907
|
+
clientId: client.id,
|
|
8908
|
+
mapName,
|
|
8909
|
+
error: errorMessage
|
|
8910
|
+
}, "Failed to register conflict resolver");
|
|
8911
|
+
}
|
|
8912
|
+
break;
|
|
8913
|
+
}
|
|
8914
|
+
case "UNREGISTER_RESOLVER": {
|
|
8915
|
+
const { requestId, mapName, resolverName } = message;
|
|
8916
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
8917
|
+
client.writer.write({
|
|
8918
|
+
type: "UNREGISTER_RESOLVER_RESPONSE",
|
|
8919
|
+
requestId,
|
|
8920
|
+
success: false,
|
|
8921
|
+
error: `Access Denied for map ${mapName}`
|
|
8922
|
+
}, true);
|
|
8923
|
+
break;
|
|
8924
|
+
}
|
|
8925
|
+
const removed = this.conflictResolverHandler.unregisterResolver(
|
|
8926
|
+
mapName,
|
|
8927
|
+
resolverName,
|
|
8928
|
+
client.id
|
|
8929
|
+
);
|
|
8930
|
+
client.writer.write({
|
|
8931
|
+
type: "UNREGISTER_RESOLVER_RESPONSE",
|
|
8932
|
+
requestId,
|
|
8933
|
+
success: removed,
|
|
8934
|
+
error: removed ? void 0 : "Resolver not found or not owned by this client"
|
|
8935
|
+
});
|
|
8936
|
+
if (removed) {
|
|
8937
|
+
logger.info({
|
|
8938
|
+
clientId: client.id,
|
|
8939
|
+
mapName,
|
|
8940
|
+
resolverName
|
|
8941
|
+
}, "Conflict resolver unregistered");
|
|
8942
|
+
}
|
|
8943
|
+
break;
|
|
8944
|
+
}
|
|
8945
|
+
case "LIST_RESOLVERS": {
|
|
8946
|
+
const { requestId, mapName } = message;
|
|
8947
|
+
if (mapName && !this.securityManager.checkPermission(client.principal, mapName, "READ")) {
|
|
8948
|
+
client.writer.write({
|
|
8949
|
+
type: "LIST_RESOLVERS_RESPONSE",
|
|
8950
|
+
requestId,
|
|
8951
|
+
resolvers: []
|
|
8952
|
+
});
|
|
8953
|
+
break;
|
|
8954
|
+
}
|
|
8955
|
+
const resolvers = this.conflictResolverHandler.listResolvers(mapName);
|
|
8956
|
+
client.writer.write({
|
|
8957
|
+
type: "LIST_RESOLVERS_RESPONSE",
|
|
8958
|
+
requestId,
|
|
8959
|
+
resolvers
|
|
8960
|
+
});
|
|
8961
|
+
break;
|
|
8962
|
+
}
|
|
7346
8963
|
// ============ Phase 4: Partition Map Request Handler ============
|
|
7347
8964
|
case "PARTITION_MAP_REQUEST": {
|
|
7348
8965
|
const clientVersion = message.payload?.currentVersion ?? 0;
|
|
@@ -7383,7 +9000,7 @@ var ServerCoordinator = class {
|
|
|
7383
9000
|
this.metricsService.incOp("GET", message.mapName);
|
|
7384
9001
|
try {
|
|
7385
9002
|
const mapForSync = await this.getMapAsync(message.mapName, "OR");
|
|
7386
|
-
if (mapForSync instanceof
|
|
9003
|
+
if (mapForSync instanceof import_core15.ORMap) {
|
|
7387
9004
|
const tree = mapForSync.getMerkleTree();
|
|
7388
9005
|
const rootHash = tree.getRootHash();
|
|
7389
9006
|
client.writer.write({
|
|
@@ -7420,7 +9037,7 @@ var ServerCoordinator = class {
|
|
|
7420
9037
|
const { mapName, path } = message.payload;
|
|
7421
9038
|
try {
|
|
7422
9039
|
const mapForBucket = await this.getMapAsync(mapName, "OR");
|
|
7423
|
-
if (mapForBucket instanceof
|
|
9040
|
+
if (mapForBucket instanceof import_core15.ORMap) {
|
|
7424
9041
|
const tree = mapForBucket.getMerkleTree();
|
|
7425
9042
|
const buckets = tree.getBuckets(path);
|
|
7426
9043
|
const isLeaf = tree.isLeaf(path);
|
|
@@ -7464,7 +9081,7 @@ var ServerCoordinator = class {
|
|
|
7464
9081
|
const { mapName: diffMapName, keys } = message.payload;
|
|
7465
9082
|
try {
|
|
7466
9083
|
const mapForDiff = await this.getMapAsync(diffMapName, "OR");
|
|
7467
|
-
if (mapForDiff instanceof
|
|
9084
|
+
if (mapForDiff instanceof import_core15.ORMap) {
|
|
7468
9085
|
const entries = [];
|
|
7469
9086
|
const allTombstones = mapForDiff.getTombstones();
|
|
7470
9087
|
for (const key of keys) {
|
|
@@ -7496,7 +9113,7 @@ var ServerCoordinator = class {
|
|
|
7496
9113
|
const { mapName: pushMapName, entries: pushEntries } = message.payload;
|
|
7497
9114
|
try {
|
|
7498
9115
|
const mapForPush = await this.getMapAsync(pushMapName, "OR");
|
|
7499
|
-
if (mapForPush instanceof
|
|
9116
|
+
if (mapForPush instanceof import_core15.ORMap) {
|
|
7500
9117
|
let totalAdded = 0;
|
|
7501
9118
|
let totalUpdated = 0;
|
|
7502
9119
|
for (const entry of pushEntries) {
|
|
@@ -7538,6 +9155,92 @@ var ServerCoordinator = class {
|
|
|
7538
9155
|
}
|
|
7539
9156
|
break;
|
|
7540
9157
|
}
|
|
9158
|
+
// === Event Journal Messages (Phase 5.04) ===
|
|
9159
|
+
case "JOURNAL_SUBSCRIBE": {
|
|
9160
|
+
if (!this.eventJournalService) {
|
|
9161
|
+
client.writer.write({
|
|
9162
|
+
type: "ERROR",
|
|
9163
|
+
payload: { code: 503, message: "Event journal not enabled" }
|
|
9164
|
+
}, true);
|
|
9165
|
+
break;
|
|
9166
|
+
}
|
|
9167
|
+
const { requestId, fromSequence, mapName, types } = message;
|
|
9168
|
+
const subscriptionId = requestId;
|
|
9169
|
+
this.journalSubscriptions.set(subscriptionId, {
|
|
9170
|
+
clientId: client.id,
|
|
9171
|
+
mapName,
|
|
9172
|
+
types
|
|
9173
|
+
});
|
|
9174
|
+
const unsubscribe = this.eventJournalService.subscribe(
|
|
9175
|
+
(event) => {
|
|
9176
|
+
if (mapName && event.mapName !== mapName) return;
|
|
9177
|
+
if (types && types.length > 0 && !types.includes(event.type)) return;
|
|
9178
|
+
const clientConn = this.clients.get(client.id);
|
|
9179
|
+
if (!clientConn) {
|
|
9180
|
+
unsubscribe();
|
|
9181
|
+
this.journalSubscriptions.delete(subscriptionId);
|
|
9182
|
+
return;
|
|
9183
|
+
}
|
|
9184
|
+
clientConn.writer.write({
|
|
9185
|
+
type: "JOURNAL_EVENT",
|
|
9186
|
+
event: {
|
|
9187
|
+
sequence: event.sequence.toString(),
|
|
9188
|
+
type: event.type,
|
|
9189
|
+
mapName: event.mapName,
|
|
9190
|
+
key: event.key,
|
|
9191
|
+
value: event.value,
|
|
9192
|
+
previousValue: event.previousValue,
|
|
9193
|
+
timestamp: event.timestamp,
|
|
9194
|
+
nodeId: event.nodeId,
|
|
9195
|
+
metadata: event.metadata
|
|
9196
|
+
}
|
|
9197
|
+
});
|
|
9198
|
+
},
|
|
9199
|
+
fromSequence ? BigInt(fromSequence) : void 0
|
|
9200
|
+
);
|
|
9201
|
+
logger.info({ clientId: client.id, subscriptionId, mapName }, "Journal subscription created");
|
|
9202
|
+
break;
|
|
9203
|
+
}
|
|
9204
|
+
case "JOURNAL_UNSUBSCRIBE": {
|
|
9205
|
+
const { subscriptionId } = message;
|
|
9206
|
+
this.journalSubscriptions.delete(subscriptionId);
|
|
9207
|
+
logger.info({ clientId: client.id, subscriptionId }, "Journal subscription removed");
|
|
9208
|
+
break;
|
|
9209
|
+
}
|
|
9210
|
+
case "JOURNAL_READ": {
|
|
9211
|
+
if (!this.eventJournalService) {
|
|
9212
|
+
client.writer.write({
|
|
9213
|
+
type: "ERROR",
|
|
9214
|
+
payload: { code: 503, message: "Event journal not enabled" }
|
|
9215
|
+
}, true);
|
|
9216
|
+
break;
|
|
9217
|
+
}
|
|
9218
|
+
const { requestId: readReqId, fromSequence: readFromSeq, limit, mapName: readMapName } = message;
|
|
9219
|
+
const startSeq = BigInt(readFromSeq);
|
|
9220
|
+
const eventLimit = limit ?? 100;
|
|
9221
|
+
let events = this.eventJournalService.readFrom(startSeq, eventLimit);
|
|
9222
|
+
if (readMapName) {
|
|
9223
|
+
events = events.filter((e) => e.mapName === readMapName);
|
|
9224
|
+
}
|
|
9225
|
+
const serializedEvents = events.map((e) => ({
|
|
9226
|
+
sequence: e.sequence.toString(),
|
|
9227
|
+
type: e.type,
|
|
9228
|
+
mapName: e.mapName,
|
|
9229
|
+
key: e.key,
|
|
9230
|
+
value: e.value,
|
|
9231
|
+
previousValue: e.previousValue,
|
|
9232
|
+
timestamp: e.timestamp,
|
|
9233
|
+
nodeId: e.nodeId,
|
|
9234
|
+
metadata: e.metadata
|
|
9235
|
+
}));
|
|
9236
|
+
client.writer.write({
|
|
9237
|
+
type: "JOURNAL_READ_RESPONSE",
|
|
9238
|
+
requestId: readReqId,
|
|
9239
|
+
events: serializedEvents,
|
|
9240
|
+
hasMore: events.length === eventLimit
|
|
9241
|
+
});
|
|
9242
|
+
break;
|
|
9243
|
+
}
|
|
7541
9244
|
default:
|
|
7542
9245
|
logger.warn({ type: message.type }, "Unknown message type");
|
|
7543
9246
|
}
|
|
@@ -7551,7 +9254,7 @@ var ServerCoordinator = class {
|
|
|
7551
9254
|
} else if (op.orRecord && op.orRecord.timestamp) {
|
|
7552
9255
|
} else if (op.orTag) {
|
|
7553
9256
|
try {
|
|
7554
|
-
ts =
|
|
9257
|
+
ts = import_core15.HLC.parse(op.orTag);
|
|
7555
9258
|
} catch (e) {
|
|
7556
9259
|
}
|
|
7557
9260
|
}
|
|
@@ -7585,6 +9288,39 @@ var ServerCoordinator = class {
|
|
|
7585
9288
|
clientCount: broadcastCount
|
|
7586
9289
|
}, "Broadcast partition map to clients");
|
|
7587
9290
|
}
|
|
9291
|
+
/**
|
|
9292
|
+
* Notify a client about a merge rejection (Phase 5.05).
|
|
9293
|
+
* Finds the client by node ID and sends MERGE_REJECTED message.
|
|
9294
|
+
*/
|
|
9295
|
+
notifyMergeRejection(rejection) {
|
|
9296
|
+
for (const [clientId, client] of this.clients) {
|
|
9297
|
+
if (clientId === rejection.nodeId || rejection.nodeId.includes(clientId)) {
|
|
9298
|
+
client.writer.write({
|
|
9299
|
+
type: "MERGE_REJECTED",
|
|
9300
|
+
mapName: rejection.mapName,
|
|
9301
|
+
key: rejection.key,
|
|
9302
|
+
attemptedValue: rejection.attemptedValue,
|
|
9303
|
+
reason: rejection.reason,
|
|
9304
|
+
timestamp: rejection.timestamp
|
|
9305
|
+
}, true);
|
|
9306
|
+
return;
|
|
9307
|
+
}
|
|
9308
|
+
}
|
|
9309
|
+
const subscribedClientIds = this.queryRegistry.getSubscribedClientIds(rejection.mapName);
|
|
9310
|
+
for (const clientId of subscribedClientIds) {
|
|
9311
|
+
const client = this.clients.get(clientId);
|
|
9312
|
+
if (client) {
|
|
9313
|
+
client.writer.write({
|
|
9314
|
+
type: "MERGE_REJECTED",
|
|
9315
|
+
mapName: rejection.mapName,
|
|
9316
|
+
key: rejection.key,
|
|
9317
|
+
attemptedValue: rejection.attemptedValue,
|
|
9318
|
+
reason: rejection.reason,
|
|
9319
|
+
timestamp: rejection.timestamp
|
|
9320
|
+
});
|
|
9321
|
+
}
|
|
9322
|
+
}
|
|
9323
|
+
}
|
|
7588
9324
|
broadcast(message, excludeClientId) {
|
|
7589
9325
|
const isServerEvent = message.type === "SERVER_EVENT";
|
|
7590
9326
|
if (isServerEvent) {
|
|
@@ -7615,7 +9351,7 @@ var ServerCoordinator = class {
|
|
|
7615
9351
|
client.writer.write({ ...message, payload: newPayload });
|
|
7616
9352
|
}
|
|
7617
9353
|
} else {
|
|
7618
|
-
const msgData = (0,
|
|
9354
|
+
const msgData = (0, import_core15.serialize)(message);
|
|
7619
9355
|
for (const [id, client] of this.clients) {
|
|
7620
9356
|
if (id !== excludeClientId && client.socket.readyState === 1) {
|
|
7621
9357
|
client.writer.writeRaw(msgData);
|
|
@@ -7693,7 +9429,7 @@ var ServerCoordinator = class {
|
|
|
7693
9429
|
payload: { events: filteredEvents },
|
|
7694
9430
|
timestamp: this.hlc.now()
|
|
7695
9431
|
};
|
|
7696
|
-
const serializedBatch = (0,
|
|
9432
|
+
const serializedBatch = (0, import_core15.serialize)(batchMessage);
|
|
7697
9433
|
for (const client of clients) {
|
|
7698
9434
|
try {
|
|
7699
9435
|
client.writer.writeRaw(serializedBatch);
|
|
@@ -7778,7 +9514,7 @@ var ServerCoordinator = class {
|
|
|
7778
9514
|
payload: { events: filteredEvents },
|
|
7779
9515
|
timestamp: this.hlc.now()
|
|
7780
9516
|
};
|
|
7781
|
-
const serializedBatch = (0,
|
|
9517
|
+
const serializedBatch = (0, import_core15.serialize)(batchMessage);
|
|
7782
9518
|
for (const client of clients) {
|
|
7783
9519
|
sendPromises.push(new Promise((resolve, reject) => {
|
|
7784
9520
|
try {
|
|
@@ -7922,14 +9658,14 @@ var ServerCoordinator = class {
|
|
|
7922
9658
|
async executeLocalQuery(mapName, query) {
|
|
7923
9659
|
const map = await this.getMapAsync(mapName);
|
|
7924
9660
|
const records = /* @__PURE__ */ new Map();
|
|
7925
|
-
if (map instanceof
|
|
9661
|
+
if (map instanceof import_core15.LWWMap) {
|
|
7926
9662
|
for (const key of map.allKeys()) {
|
|
7927
9663
|
const rec = map.getRecord(key);
|
|
7928
9664
|
if (rec && rec.value !== null) {
|
|
7929
9665
|
records.set(key, rec);
|
|
7930
9666
|
}
|
|
7931
9667
|
}
|
|
7932
|
-
} else if (map instanceof
|
|
9668
|
+
} else if (map instanceof import_core15.ORMap) {
|
|
7933
9669
|
const items = map.items;
|
|
7934
9670
|
for (const key of items.keys()) {
|
|
7935
9671
|
const values = map.get(key);
|
|
@@ -7996,14 +9732,14 @@ var ServerCoordinator = class {
|
|
|
7996
9732
|
*
|
|
7997
9733
|
* @returns Event payload for broadcasting (or null if operation failed)
|
|
7998
9734
|
*/
|
|
7999
|
-
applyOpToMap(op) {
|
|
9735
|
+
async applyOpToMap(op, remoteNodeId) {
|
|
8000
9736
|
const typeHint = op.opType === "OR_ADD" || op.opType === "OR_REMOVE" ? "OR" : "LWW";
|
|
8001
9737
|
const map = this.getMap(op.mapName, typeHint);
|
|
8002
|
-
if (typeHint === "OR" && map instanceof
|
|
9738
|
+
if (typeHint === "OR" && map instanceof import_core15.LWWMap) {
|
|
8003
9739
|
logger.error({ mapName: op.mapName }, "Map type mismatch: LWWMap but received OR op");
|
|
8004
9740
|
throw new Error("Map type mismatch: LWWMap but received OR op");
|
|
8005
9741
|
}
|
|
8006
|
-
if (typeHint === "LWW" && map instanceof
|
|
9742
|
+
if (typeHint === "LWW" && map instanceof import_core15.ORMap) {
|
|
8007
9743
|
logger.error({ mapName: op.mapName }, "Map type mismatch: ORMap but received LWW op");
|
|
8008
9744
|
throw new Error("Map type mismatch: ORMap but received LWW op");
|
|
8009
9745
|
}
|
|
@@ -8014,13 +9750,35 @@ var ServerCoordinator = class {
|
|
|
8014
9750
|
mapName: op.mapName,
|
|
8015
9751
|
key: op.key
|
|
8016
9752
|
};
|
|
8017
|
-
if (map instanceof
|
|
9753
|
+
if (map instanceof import_core15.LWWMap) {
|
|
8018
9754
|
oldRecord = map.getRecord(op.key);
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
9755
|
+
if (this.conflictResolverHandler.hasResolvers(op.mapName)) {
|
|
9756
|
+
const mergeResult = await this.conflictResolverHandler.mergeWithResolver(
|
|
9757
|
+
map,
|
|
9758
|
+
op.mapName,
|
|
9759
|
+
op.key,
|
|
9760
|
+
op.record,
|
|
9761
|
+
remoteNodeId || this._nodeId
|
|
9762
|
+
);
|
|
9763
|
+
if (!mergeResult.applied) {
|
|
9764
|
+
if (mergeResult.rejection) {
|
|
9765
|
+
logger.debug(
|
|
9766
|
+
{ mapName: op.mapName, key: op.key, reason: mergeResult.rejection.reason },
|
|
9767
|
+
"Merge rejected by resolver"
|
|
9768
|
+
);
|
|
9769
|
+
}
|
|
9770
|
+
return { eventPayload: null, oldRecord, rejected: true };
|
|
9771
|
+
}
|
|
9772
|
+
recordToStore = mergeResult.record;
|
|
9773
|
+
eventPayload.eventType = "UPDATED";
|
|
9774
|
+
eventPayload.record = mergeResult.record;
|
|
9775
|
+
} else {
|
|
9776
|
+
map.merge(op.key, op.record);
|
|
9777
|
+
recordToStore = op.record;
|
|
9778
|
+
eventPayload.eventType = "UPDATED";
|
|
9779
|
+
eventPayload.record = op.record;
|
|
9780
|
+
}
|
|
9781
|
+
} else if (map instanceof import_core15.ORMap) {
|
|
8024
9782
|
oldRecord = map.getRecords(op.key);
|
|
8025
9783
|
if (op.opType === "OR_ADD") {
|
|
8026
9784
|
map.apply(op.key, op.orRecord);
|
|
@@ -8036,7 +9794,7 @@ var ServerCoordinator = class {
|
|
|
8036
9794
|
}
|
|
8037
9795
|
}
|
|
8038
9796
|
this.queryRegistry.processChange(op.mapName, map, op.key, op.record || op.orRecord, oldRecord);
|
|
8039
|
-
const mapSize = map instanceof
|
|
9797
|
+
const mapSize = map instanceof import_core15.ORMap ? map.totalRecords : map.size;
|
|
8040
9798
|
this.metricsService.setMapSize(op.mapName, mapSize);
|
|
8041
9799
|
if (this.storage) {
|
|
8042
9800
|
if (recordToStore) {
|
|
@@ -8050,6 +9808,21 @@ var ServerCoordinator = class {
|
|
|
8050
9808
|
});
|
|
8051
9809
|
}
|
|
8052
9810
|
}
|
|
9811
|
+
if (this.eventJournalService) {
|
|
9812
|
+
const isDelete = op.opType === "REMOVE" || op.opType === "OR_REMOVE" || op.record && op.record.value === null;
|
|
9813
|
+
const isNew = !oldRecord || Array.isArray(oldRecord) && oldRecord.length === 0;
|
|
9814
|
+
const journalEventType = isDelete ? "DELETE" : isNew ? "PUT" : "UPDATE";
|
|
9815
|
+
const timestamp = op.record?.timestamp || op.orRecord?.timestamp || this.hlc.now();
|
|
9816
|
+
this.eventJournalService.append({
|
|
9817
|
+
type: journalEventType,
|
|
9818
|
+
mapName: op.mapName,
|
|
9819
|
+
key: op.key,
|
|
9820
|
+
value: op.record?.value ?? op.orRecord?.value,
|
|
9821
|
+
previousValue: oldRecord?.value ?? (Array.isArray(oldRecord) ? oldRecord[0]?.value : void 0),
|
|
9822
|
+
timestamp,
|
|
9823
|
+
nodeId: this._nodeId
|
|
9824
|
+
});
|
|
9825
|
+
}
|
|
8053
9826
|
return { eventPayload, oldRecord };
|
|
8054
9827
|
}
|
|
8055
9828
|
/**
|
|
@@ -8071,7 +9844,10 @@ var ServerCoordinator = class {
|
|
|
8071
9844
|
try {
|
|
8072
9845
|
const op = operation;
|
|
8073
9846
|
logger.debug({ sourceNode, opId, mapName: op.mapName, key: op.key }, "Applying replicated operation");
|
|
8074
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
9847
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, sourceNode);
|
|
9848
|
+
if (rejected || !eventPayload) {
|
|
9849
|
+
return true;
|
|
9850
|
+
}
|
|
8075
9851
|
this.broadcast({
|
|
8076
9852
|
type: "SERVER_EVENT",
|
|
8077
9853
|
payload: eventPayload,
|
|
@@ -8170,7 +9946,10 @@ var ServerCoordinator = class {
|
|
|
8170
9946
|
logger.warn({ err, opId: op.id }, "Interceptor rejected op");
|
|
8171
9947
|
throw err;
|
|
8172
9948
|
}
|
|
8173
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
9949
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, originalSenderId);
|
|
9950
|
+
if (rejected || !eventPayload) {
|
|
9951
|
+
return;
|
|
9952
|
+
}
|
|
8174
9953
|
if (this.replicationPipeline && !fromCluster) {
|
|
8175
9954
|
const opId = op.id || `${op.mapName}:${op.key}:${Date.now()}`;
|
|
8176
9955
|
this.replicationPipeline.replicate(op, opId, op.key).catch((err) => {
|
|
@@ -8298,7 +10077,10 @@ var ServerCoordinator = class {
|
|
|
8298
10077
|
logger.warn({ err, opId: op.id }, "Interceptor rejected op in batch");
|
|
8299
10078
|
throw err;
|
|
8300
10079
|
}
|
|
8301
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
10080
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, clientId);
|
|
10081
|
+
if (rejected || !eventPayload) {
|
|
10082
|
+
return;
|
|
10083
|
+
}
|
|
8302
10084
|
if (this.replicationPipeline) {
|
|
8303
10085
|
const opId = op.id || `${op.mapName}:${op.key}:${Date.now()}`;
|
|
8304
10086
|
this.replicationPipeline.replicate(op, opId, op.key).catch((err) => {
|
|
@@ -8312,11 +10094,11 @@ var ServerCoordinator = class {
|
|
|
8312
10094
|
handleClusterEvent(payload) {
|
|
8313
10095
|
const { mapName, key, eventType } = payload;
|
|
8314
10096
|
const map = this.getMap(mapName, eventType === "OR_ADD" || eventType === "OR_REMOVE" ? "OR" : "LWW");
|
|
8315
|
-
const oldRecord = map instanceof
|
|
10097
|
+
const oldRecord = map instanceof import_core15.LWWMap ? map.getRecord(key) : null;
|
|
8316
10098
|
if (this.partitionService.isRelated(key)) {
|
|
8317
|
-
if (map instanceof
|
|
10099
|
+
if (map instanceof import_core15.LWWMap && payload.record) {
|
|
8318
10100
|
map.merge(key, payload.record);
|
|
8319
|
-
} else if (map instanceof
|
|
10101
|
+
} else if (map instanceof import_core15.ORMap) {
|
|
8320
10102
|
if (eventType === "OR_ADD" && payload.orRecord) {
|
|
8321
10103
|
map.apply(key, payload.orRecord);
|
|
8322
10104
|
} else if (eventType === "OR_REMOVE" && payload.orTag) {
|
|
@@ -8335,9 +10117,9 @@ var ServerCoordinator = class {
|
|
|
8335
10117
|
if (!this.maps.has(name)) {
|
|
8336
10118
|
let map;
|
|
8337
10119
|
if (typeHint === "OR") {
|
|
8338
|
-
map = new
|
|
10120
|
+
map = new import_core15.ORMap(this.hlc);
|
|
8339
10121
|
} else {
|
|
8340
|
-
map = new
|
|
10122
|
+
map = new import_core15.LWWMap(this.hlc);
|
|
8341
10123
|
}
|
|
8342
10124
|
this.maps.set(name, map);
|
|
8343
10125
|
if (this.storage) {
|
|
@@ -8360,7 +10142,7 @@ var ServerCoordinator = class {
|
|
|
8360
10142
|
this.getMap(name, typeHint);
|
|
8361
10143
|
const loadingPromise = this.mapLoadingPromises.get(name);
|
|
8362
10144
|
const map = this.maps.get(name);
|
|
8363
|
-
const mapSize = map instanceof
|
|
10145
|
+
const mapSize = map instanceof import_core15.LWWMap ? Array.from(map.entries()).length : map instanceof import_core15.ORMap ? map.size : 0;
|
|
8364
10146
|
logger.info({
|
|
8365
10147
|
mapName: name,
|
|
8366
10148
|
mapExisted,
|
|
@@ -8370,7 +10152,7 @@ var ServerCoordinator = class {
|
|
|
8370
10152
|
if (loadingPromise) {
|
|
8371
10153
|
logger.info({ mapName: name }, "[getMapAsync] Waiting for loadMapFromStorage...");
|
|
8372
10154
|
await loadingPromise;
|
|
8373
|
-
const newMapSize = map instanceof
|
|
10155
|
+
const newMapSize = map instanceof import_core15.LWWMap ? Array.from(map.entries()).length : map instanceof import_core15.ORMap ? map.size : 0;
|
|
8374
10156
|
logger.info({ mapName: name, mapSizeAfterLoad: newMapSize }, "[getMapAsync] Load completed");
|
|
8375
10157
|
}
|
|
8376
10158
|
return this.maps.get(name);
|
|
@@ -8396,16 +10178,16 @@ var ServerCoordinator = class {
|
|
|
8396
10178
|
const currentMap = this.maps.get(name);
|
|
8397
10179
|
if (!currentMap) return;
|
|
8398
10180
|
let targetMap = currentMap;
|
|
8399
|
-
if (isOR && currentMap instanceof
|
|
10181
|
+
if (isOR && currentMap instanceof import_core15.LWWMap) {
|
|
8400
10182
|
logger.info({ mapName: name }, "Map auto-detected as ORMap. Switching type.");
|
|
8401
|
-
targetMap = new
|
|
10183
|
+
targetMap = new import_core15.ORMap(this.hlc);
|
|
8402
10184
|
this.maps.set(name, targetMap);
|
|
8403
|
-
} else if (!isOR && currentMap instanceof
|
|
10185
|
+
} else if (!isOR && currentMap instanceof import_core15.ORMap && typeHint !== "OR") {
|
|
8404
10186
|
logger.info({ mapName: name }, "Map auto-detected as LWWMap. Switching type.");
|
|
8405
|
-
targetMap = new
|
|
10187
|
+
targetMap = new import_core15.LWWMap(this.hlc);
|
|
8406
10188
|
this.maps.set(name, targetMap);
|
|
8407
10189
|
}
|
|
8408
|
-
if (targetMap instanceof
|
|
10190
|
+
if (targetMap instanceof import_core15.ORMap) {
|
|
8409
10191
|
for (const [key, record] of records) {
|
|
8410
10192
|
if (key === "__tombstones__") {
|
|
8411
10193
|
const t = record;
|
|
@@ -8418,7 +10200,7 @@ var ServerCoordinator = class {
|
|
|
8418
10200
|
}
|
|
8419
10201
|
}
|
|
8420
10202
|
}
|
|
8421
|
-
} else if (targetMap instanceof
|
|
10203
|
+
} else if (targetMap instanceof import_core15.LWWMap) {
|
|
8422
10204
|
for (const [key, record] of records) {
|
|
8423
10205
|
if (!record.type) {
|
|
8424
10206
|
targetMap.merge(key, record);
|
|
@@ -8429,7 +10211,7 @@ var ServerCoordinator = class {
|
|
|
8429
10211
|
if (count > 0) {
|
|
8430
10212
|
logger.info({ mapName: name, count }, "Loaded records for map");
|
|
8431
10213
|
this.queryRegistry.refreshSubscriptions(name, targetMap);
|
|
8432
|
-
const mapSize = targetMap instanceof
|
|
10214
|
+
const mapSize = targetMap instanceof import_core15.ORMap ? targetMap.totalRecords : targetMap.size;
|
|
8433
10215
|
this.metricsService.setMapSize(name, mapSize);
|
|
8434
10216
|
}
|
|
8435
10217
|
} catch (err) {
|
|
@@ -8511,7 +10293,7 @@ var ServerCoordinator = class {
|
|
|
8511
10293
|
reportLocalHlc() {
|
|
8512
10294
|
let minHlc = this.hlc.now();
|
|
8513
10295
|
for (const client of this.clients.values()) {
|
|
8514
|
-
if (
|
|
10296
|
+
if (import_core15.HLC.compare(client.lastActiveHlc, minHlc) < 0) {
|
|
8515
10297
|
minHlc = client.lastActiveHlc;
|
|
8516
10298
|
}
|
|
8517
10299
|
}
|
|
@@ -8532,7 +10314,7 @@ var ServerCoordinator = class {
|
|
|
8532
10314
|
let globalSafe = this.hlc.now();
|
|
8533
10315
|
let initialized = false;
|
|
8534
10316
|
for (const ts of this.gcReports.values()) {
|
|
8535
|
-
if (!initialized ||
|
|
10317
|
+
if (!initialized || import_core15.HLC.compare(ts, globalSafe) < 0) {
|
|
8536
10318
|
globalSafe = ts;
|
|
8537
10319
|
initialized = true;
|
|
8538
10320
|
}
|
|
@@ -8567,7 +10349,7 @@ var ServerCoordinator = class {
|
|
|
8567
10349
|
logger.info({ olderThanMillis: olderThan.millis }, "Performing Garbage Collection");
|
|
8568
10350
|
const now = Date.now();
|
|
8569
10351
|
for (const [name, map] of this.maps) {
|
|
8570
|
-
if (map instanceof
|
|
10352
|
+
if (map instanceof import_core15.LWWMap) {
|
|
8571
10353
|
for (const key of map.allKeys()) {
|
|
8572
10354
|
const record = map.getRecord(key);
|
|
8573
10355
|
if (record && record.value !== null && record.ttlMs) {
|
|
@@ -8619,7 +10401,7 @@ var ServerCoordinator = class {
|
|
|
8619
10401
|
});
|
|
8620
10402
|
}
|
|
8621
10403
|
}
|
|
8622
|
-
} else if (map instanceof
|
|
10404
|
+
} else if (map instanceof import_core15.ORMap) {
|
|
8623
10405
|
const items = map.items;
|
|
8624
10406
|
const tombstonesSet = map.tombstones;
|
|
8625
10407
|
const tagsToExpire = [];
|
|
@@ -8722,17 +10504,17 @@ var ServerCoordinator = class {
|
|
|
8722
10504
|
stringToWriteConcern(value) {
|
|
8723
10505
|
switch (value) {
|
|
8724
10506
|
case "FIRE_AND_FORGET":
|
|
8725
|
-
return
|
|
10507
|
+
return import_core15.WriteConcern.FIRE_AND_FORGET;
|
|
8726
10508
|
case "MEMORY":
|
|
8727
|
-
return
|
|
10509
|
+
return import_core15.WriteConcern.MEMORY;
|
|
8728
10510
|
case "APPLIED":
|
|
8729
|
-
return
|
|
10511
|
+
return import_core15.WriteConcern.APPLIED;
|
|
8730
10512
|
case "REPLICATED":
|
|
8731
|
-
return
|
|
10513
|
+
return import_core15.WriteConcern.REPLICATED;
|
|
8732
10514
|
case "PERSISTED":
|
|
8733
|
-
return
|
|
10515
|
+
return import_core15.WriteConcern.PERSISTED;
|
|
8734
10516
|
default:
|
|
8735
|
-
return
|
|
10517
|
+
return import_core15.WriteConcern.MEMORY;
|
|
8736
10518
|
}
|
|
8737
10519
|
}
|
|
8738
10520
|
/**
|
|
@@ -8789,7 +10571,7 @@ var ServerCoordinator = class {
|
|
|
8789
10571
|
}
|
|
8790
10572
|
});
|
|
8791
10573
|
if (op.id) {
|
|
8792
|
-
this.writeAckManager.notifyLevel(op.id,
|
|
10574
|
+
this.writeAckManager.notifyLevel(op.id, import_core15.WriteConcern.REPLICATED);
|
|
8793
10575
|
}
|
|
8794
10576
|
}
|
|
8795
10577
|
}
|
|
@@ -8797,7 +10579,7 @@ var ServerCoordinator = class {
|
|
|
8797
10579
|
this.broadcastBatch(batchedEvents, clientId);
|
|
8798
10580
|
for (const op of ops) {
|
|
8799
10581
|
if (op.id && this.partitionService.isLocalOwner(op.key)) {
|
|
8800
|
-
this.writeAckManager.notifyLevel(op.id,
|
|
10582
|
+
this.writeAckManager.notifyLevel(op.id, import_core15.WriteConcern.REPLICATED);
|
|
8801
10583
|
}
|
|
8802
10584
|
}
|
|
8803
10585
|
}
|
|
@@ -8825,7 +10607,7 @@ var ServerCoordinator = class {
|
|
|
8825
10607
|
const owner = this.partitionService.getOwner(op.key);
|
|
8826
10608
|
await this.forwardOpAndWait(op, owner);
|
|
8827
10609
|
if (op.id) {
|
|
8828
|
-
this.writeAckManager.notifyLevel(op.id,
|
|
10610
|
+
this.writeAckManager.notifyLevel(op.id, import_core15.WriteConcern.REPLICATED);
|
|
8829
10611
|
}
|
|
8830
10612
|
}
|
|
8831
10613
|
}
|
|
@@ -8833,7 +10615,7 @@ var ServerCoordinator = class {
|
|
|
8833
10615
|
await this.broadcastBatchSync(batchedEvents, clientId);
|
|
8834
10616
|
for (const op of ops) {
|
|
8835
10617
|
if (op.id && this.partitionService.isLocalOwner(op.key)) {
|
|
8836
|
-
this.writeAckManager.notifyLevel(op.id,
|
|
10618
|
+
this.writeAckManager.notifyLevel(op.id, import_core15.WriteConcern.REPLICATED);
|
|
8837
10619
|
}
|
|
8838
10620
|
}
|
|
8839
10621
|
}
|
|
@@ -8859,9 +10641,15 @@ var ServerCoordinator = class {
|
|
|
8859
10641
|
}
|
|
8860
10642
|
return;
|
|
8861
10643
|
}
|
|
8862
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
10644
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, clientId);
|
|
10645
|
+
if (rejected) {
|
|
10646
|
+
if (op.id) {
|
|
10647
|
+
this.writeAckManager.failPending(op.id, "Rejected by conflict resolver");
|
|
10648
|
+
}
|
|
10649
|
+
return;
|
|
10650
|
+
}
|
|
8863
10651
|
if (op.id) {
|
|
8864
|
-
this.writeAckManager.notifyLevel(op.id,
|
|
10652
|
+
this.writeAckManager.notifyLevel(op.id, import_core15.WriteConcern.APPLIED);
|
|
8865
10653
|
}
|
|
8866
10654
|
if (eventPayload) {
|
|
8867
10655
|
batchedEvents.push({
|
|
@@ -8875,7 +10663,7 @@ var ServerCoordinator = class {
|
|
|
8875
10663
|
try {
|
|
8876
10664
|
await this.persistOpSync(op);
|
|
8877
10665
|
if (op.id) {
|
|
8878
|
-
this.writeAckManager.notifyLevel(op.id,
|
|
10666
|
+
this.writeAckManager.notifyLevel(op.id, import_core15.WriteConcern.PERSISTED);
|
|
8879
10667
|
}
|
|
8880
10668
|
} catch (err) {
|
|
8881
10669
|
logger.error({ opId: op.id, err }, "Persistence failed");
|
|
@@ -8941,9 +10729,9 @@ var ServerCoordinator = class {
|
|
|
8941
10729
|
// src/storage/PostgresAdapter.ts
|
|
8942
10730
|
var import_pg = require("pg");
|
|
8943
10731
|
var DEFAULT_TABLE_NAME = "topgun_maps";
|
|
8944
|
-
var
|
|
8945
|
-
function
|
|
8946
|
-
if (!
|
|
10732
|
+
var TABLE_NAME_REGEX2 = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
10733
|
+
function validateTableName2(name) {
|
|
10734
|
+
if (!TABLE_NAME_REGEX2.test(name)) {
|
|
8947
10735
|
throw new Error(
|
|
8948
10736
|
`Invalid table name "${name}". Table name must start with a letter or underscore and contain only alphanumeric characters and underscores.`
|
|
8949
10737
|
);
|
|
@@ -8957,7 +10745,7 @@ var PostgresAdapter = class {
|
|
|
8957
10745
|
this.pool = new import_pg.Pool(configOrPool);
|
|
8958
10746
|
}
|
|
8959
10747
|
const tableName = options?.tableName ?? DEFAULT_TABLE_NAME;
|
|
8960
|
-
|
|
10748
|
+
validateTableName2(tableName);
|
|
8961
10749
|
this.tableName = tableName;
|
|
8962
10750
|
}
|
|
8963
10751
|
async initialize() {
|
|
@@ -9218,10 +11006,10 @@ var RateLimitInterceptor = class {
|
|
|
9218
11006
|
};
|
|
9219
11007
|
|
|
9220
11008
|
// src/utils/nativeStats.ts
|
|
9221
|
-
var
|
|
11009
|
+
var import_core16 = require("@topgunbuild/core");
|
|
9222
11010
|
function getNativeModuleStatus() {
|
|
9223
11011
|
return {
|
|
9224
|
-
nativeHash: (0,
|
|
11012
|
+
nativeHash: (0, import_core16.isUsingNativeHash)(),
|
|
9225
11013
|
sharedArrayBuffer: SharedMemoryManager.isAvailable()
|
|
9226
11014
|
};
|
|
9227
11015
|
}
|
|
@@ -9255,11 +11043,11 @@ function logNativeStatus() {
|
|
|
9255
11043
|
|
|
9256
11044
|
// src/cluster/ClusterCoordinator.ts
|
|
9257
11045
|
var import_events9 = require("events");
|
|
9258
|
-
var
|
|
11046
|
+
var import_core17 = require("@topgunbuild/core");
|
|
9259
11047
|
var DEFAULT_CLUSTER_COORDINATOR_CONFIG = {
|
|
9260
11048
|
gradualRebalancing: true,
|
|
9261
|
-
migration:
|
|
9262
|
-
replication:
|
|
11049
|
+
migration: import_core17.DEFAULT_MIGRATION_CONFIG,
|
|
11050
|
+
replication: import_core17.DEFAULT_REPLICATION_CONFIG,
|
|
9263
11051
|
replicationEnabled: true
|
|
9264
11052
|
};
|
|
9265
11053
|
var ClusterCoordinator = class extends import_events9.EventEmitter {
|
|
@@ -9625,25 +11413,224 @@ var ClusterCoordinator = class extends import_events9.EventEmitter {
|
|
|
9625
11413
|
}
|
|
9626
11414
|
}
|
|
9627
11415
|
};
|
|
11416
|
+
|
|
11417
|
+
// src/MapWithResolver.ts
|
|
11418
|
+
var import_core18 = require("@topgunbuild/core");
|
|
11419
|
+
var MapWithResolver = class {
|
|
11420
|
+
constructor(config) {
|
|
11421
|
+
this.mapName = config.name;
|
|
11422
|
+
this.hlc = new import_core18.HLC(config.nodeId);
|
|
11423
|
+
this.map = new import_core18.LWWMap(this.hlc);
|
|
11424
|
+
this.resolverService = config.resolverService;
|
|
11425
|
+
this.onRejection = config.onRejection;
|
|
11426
|
+
}
|
|
11427
|
+
/**
|
|
11428
|
+
* Get the map name.
|
|
11429
|
+
*/
|
|
11430
|
+
get name() {
|
|
11431
|
+
return this.mapName;
|
|
11432
|
+
}
|
|
11433
|
+
/**
|
|
11434
|
+
* Get the underlying LWWMap.
|
|
11435
|
+
*/
|
|
11436
|
+
get rawMap() {
|
|
11437
|
+
return this.map;
|
|
11438
|
+
}
|
|
11439
|
+
/**
|
|
11440
|
+
* Get a value by key.
|
|
11441
|
+
*/
|
|
11442
|
+
get(key) {
|
|
11443
|
+
return this.map.get(key);
|
|
11444
|
+
}
|
|
11445
|
+
/**
|
|
11446
|
+
* Get the full record for a key.
|
|
11447
|
+
*/
|
|
11448
|
+
getRecord(key) {
|
|
11449
|
+
return this.map.getRecord(key);
|
|
11450
|
+
}
|
|
11451
|
+
/**
|
|
11452
|
+
* Get the timestamp for a key.
|
|
11453
|
+
*/
|
|
11454
|
+
getTimestamp(key) {
|
|
11455
|
+
return this.map.getRecord(key)?.timestamp;
|
|
11456
|
+
}
|
|
11457
|
+
/**
|
|
11458
|
+
* Set a value locally (no resolver).
|
|
11459
|
+
* Use for server-initiated writes.
|
|
11460
|
+
*/
|
|
11461
|
+
set(key, value, ttlMs) {
|
|
11462
|
+
return this.map.set(key, value, ttlMs);
|
|
11463
|
+
}
|
|
11464
|
+
/**
|
|
11465
|
+
* Set a value with conflict resolution.
|
|
11466
|
+
* Use for client-initiated writes.
|
|
11467
|
+
*
|
|
11468
|
+
* @param key The key to set
|
|
11469
|
+
* @param value The new value
|
|
11470
|
+
* @param timestamp The client's timestamp
|
|
11471
|
+
* @param remoteNodeId The client's node ID
|
|
11472
|
+
* @param auth Optional authentication context
|
|
11473
|
+
* @returns Result containing applied status and merge result
|
|
11474
|
+
*/
|
|
11475
|
+
async setWithResolver(key, value, timestamp, remoteNodeId, auth) {
|
|
11476
|
+
const context = {
|
|
11477
|
+
mapName: this.mapName,
|
|
11478
|
+
key,
|
|
11479
|
+
localValue: this.map.get(key),
|
|
11480
|
+
remoteValue: value,
|
|
11481
|
+
localTimestamp: this.getTimestamp(key),
|
|
11482
|
+
remoteTimestamp: timestamp,
|
|
11483
|
+
remoteNodeId,
|
|
11484
|
+
auth,
|
|
11485
|
+
readEntry: (k) => this.map.get(k)
|
|
11486
|
+
};
|
|
11487
|
+
const result = await this.resolverService.resolve(context);
|
|
11488
|
+
switch (result.action) {
|
|
11489
|
+
case "accept": {
|
|
11490
|
+
const record2 = {
|
|
11491
|
+
value: result.value,
|
|
11492
|
+
timestamp
|
|
11493
|
+
};
|
|
11494
|
+
this.map.merge(key, record2);
|
|
11495
|
+
return { applied: true, result, record: record2 };
|
|
11496
|
+
}
|
|
11497
|
+
case "merge": {
|
|
11498
|
+
const record2 = {
|
|
11499
|
+
value: result.value,
|
|
11500
|
+
timestamp
|
|
11501
|
+
};
|
|
11502
|
+
this.map.merge(key, record2);
|
|
11503
|
+
return { applied: true, result, record: record2 };
|
|
11504
|
+
}
|
|
11505
|
+
case "reject": {
|
|
11506
|
+
if (this.onRejection) {
|
|
11507
|
+
this.onRejection({
|
|
11508
|
+
mapName: this.mapName,
|
|
11509
|
+
key,
|
|
11510
|
+
attemptedValue: value,
|
|
11511
|
+
reason: result.reason,
|
|
11512
|
+
timestamp,
|
|
11513
|
+
nodeId: remoteNodeId
|
|
11514
|
+
});
|
|
11515
|
+
}
|
|
11516
|
+
return { applied: false, result };
|
|
11517
|
+
}
|
|
11518
|
+
case "local": {
|
|
11519
|
+
return { applied: false, result };
|
|
11520
|
+
}
|
|
11521
|
+
default:
|
|
11522
|
+
const record = {
|
|
11523
|
+
value: result.value ?? value,
|
|
11524
|
+
timestamp
|
|
11525
|
+
};
|
|
11526
|
+
this.map.merge(key, record);
|
|
11527
|
+
return { applied: true, result, record };
|
|
11528
|
+
}
|
|
11529
|
+
}
|
|
11530
|
+
/**
|
|
11531
|
+
* Remove a key.
|
|
11532
|
+
*/
|
|
11533
|
+
remove(key) {
|
|
11534
|
+
return this.map.remove(key);
|
|
11535
|
+
}
|
|
11536
|
+
/**
|
|
11537
|
+
* Standard merge without resolver (for sync operations).
|
|
11538
|
+
*/
|
|
11539
|
+
merge(key, record) {
|
|
11540
|
+
return this.map.merge(key, record);
|
|
11541
|
+
}
|
|
11542
|
+
/**
|
|
11543
|
+
* Merge with resolver support.
|
|
11544
|
+
* Equivalent to setWithResolver but takes a full record.
|
|
11545
|
+
*/
|
|
11546
|
+
async mergeWithResolver(key, record, remoteNodeId, auth) {
|
|
11547
|
+
if (record.value === null) {
|
|
11548
|
+
const applied = this.map.merge(key, record);
|
|
11549
|
+
return {
|
|
11550
|
+
applied,
|
|
11551
|
+
result: applied ? { action: "accept", value: record.value } : { action: "local" },
|
|
11552
|
+
record: applied ? record : void 0
|
|
11553
|
+
};
|
|
11554
|
+
}
|
|
11555
|
+
return this.setWithResolver(
|
|
11556
|
+
key,
|
|
11557
|
+
record.value,
|
|
11558
|
+
record.timestamp,
|
|
11559
|
+
remoteNodeId,
|
|
11560
|
+
auth
|
|
11561
|
+
);
|
|
11562
|
+
}
|
|
11563
|
+
/**
|
|
11564
|
+
* Clear all data.
|
|
11565
|
+
*/
|
|
11566
|
+
clear() {
|
|
11567
|
+
this.map.clear();
|
|
11568
|
+
}
|
|
11569
|
+
/**
|
|
11570
|
+
* Get map size.
|
|
11571
|
+
*/
|
|
11572
|
+
get size() {
|
|
11573
|
+
return this.map.size;
|
|
11574
|
+
}
|
|
11575
|
+
/**
|
|
11576
|
+
* Iterate over entries.
|
|
11577
|
+
*/
|
|
11578
|
+
entries() {
|
|
11579
|
+
return this.map.entries();
|
|
11580
|
+
}
|
|
11581
|
+
/**
|
|
11582
|
+
* Get all keys.
|
|
11583
|
+
*/
|
|
11584
|
+
allKeys() {
|
|
11585
|
+
return this.map.allKeys();
|
|
11586
|
+
}
|
|
11587
|
+
/**
|
|
11588
|
+
* Subscribe to changes.
|
|
11589
|
+
*/
|
|
11590
|
+
onChange(callback) {
|
|
11591
|
+
return this.map.onChange(callback);
|
|
11592
|
+
}
|
|
11593
|
+
/**
|
|
11594
|
+
* Get MerkleTree for sync.
|
|
11595
|
+
*/
|
|
11596
|
+
getMerkleTree() {
|
|
11597
|
+
return this.map.getMerkleTree();
|
|
11598
|
+
}
|
|
11599
|
+
/**
|
|
11600
|
+
* Prune old tombstones.
|
|
11601
|
+
*/
|
|
11602
|
+
prune(olderThan) {
|
|
11603
|
+
return this.map.prune(olderThan);
|
|
11604
|
+
}
|
|
11605
|
+
};
|
|
9628
11606
|
// Annotate the CommonJS export names for ESM import in node:
|
|
9629
11607
|
0 && (module.exports = {
|
|
9630
11608
|
BufferPool,
|
|
9631
11609
|
ClusterCoordinator,
|
|
9632
11610
|
ClusterManager,
|
|
11611
|
+
ConflictResolverHandler,
|
|
11612
|
+
ConflictResolverService,
|
|
9633
11613
|
ConnectionRateLimiter,
|
|
9634
11614
|
DEFAULT_CLUSTER_COORDINATOR_CONFIG,
|
|
11615
|
+
DEFAULT_CONFLICT_RESOLVER_CONFIG,
|
|
11616
|
+
DEFAULT_JOURNAL_SERVICE_CONFIG,
|
|
9635
11617
|
DEFAULT_LAG_TRACKER_CONFIG,
|
|
11618
|
+
DEFAULT_SANDBOX_CONFIG,
|
|
11619
|
+
EntryProcessorHandler,
|
|
11620
|
+
EventJournalService,
|
|
9636
11621
|
FilterTasklet,
|
|
9637
11622
|
ForEachTasklet,
|
|
9638
11623
|
IteratorTasklet,
|
|
9639
11624
|
LagTracker,
|
|
9640
11625
|
LockManager,
|
|
9641
11626
|
MapTasklet,
|
|
11627
|
+
MapWithResolver,
|
|
9642
11628
|
MemoryServerAdapter,
|
|
9643
11629
|
MigrationManager,
|
|
9644
11630
|
ObjectPool,
|
|
9645
11631
|
PartitionService,
|
|
9646
11632
|
PostgresAdapter,
|
|
11633
|
+
ProcessorSandbox,
|
|
9647
11634
|
RateLimitInterceptor,
|
|
9648
11635
|
ReduceTasklet,
|
|
9649
11636
|
ReplicationPipeline,
|