@topgunbuild/server 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +827 -3
- package/dist/index.d.ts +827 -3
- package/dist/index.js +2672 -183
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2638 -142
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -11
- package/LICENSE +0 -97
package/dist/index.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { createServer as createHttpServer } from "http";
|
|
|
10
10
|
import { createServer as createHttpsServer } from "https";
|
|
11
11
|
import { readFileSync as readFileSync2 } from "fs";
|
|
12
12
|
import { WebSocketServer as WebSocketServer2, WebSocket as WebSocket3 } from "ws";
|
|
13
|
-
import { HLC, LWWMap as
|
|
13
|
+
import { HLC as HLC2, LWWMap as LWWMap3, ORMap as ORMap2, serialize as serialize4, deserialize, MessageSchema, WriteConcern as WriteConcern2, ConsistencyLevel as ConsistencyLevel2, DEFAULT_REPLICATION_CONFIG as DEFAULT_REPLICATION_CONFIG2, IndexedLWWMap as IndexedLWWMap2, IndexedORMap as IndexedORMap2 } from "@topgunbuild/core";
|
|
14
14
|
import * as jwt from "jsonwebtoken";
|
|
15
15
|
import * as crypto from "crypto";
|
|
16
16
|
|
|
@@ -101,7 +101,7 @@ function executeQuery(records, query) {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
// src/query/QueryRegistry.ts
|
|
104
|
-
import { serialize } from "@topgunbuild/core";
|
|
104
|
+
import { serialize, IndexedLWWMap } from "@topgunbuild/core";
|
|
105
105
|
|
|
106
106
|
// src/utils/logger.ts
|
|
107
107
|
import pino from "pino";
|
|
@@ -362,14 +362,70 @@ var QueryRegistry = class {
|
|
|
362
362
|
/**
|
|
363
363
|
* Processes a record change for all relevant subscriptions.
|
|
364
364
|
* Calculates diffs and sends updates.
|
|
365
|
+
*
|
|
366
|
+
* For IndexedLWWMap: Uses StandingQueryRegistry for O(1) affected query detection.
|
|
367
|
+
* For regular maps: Falls back to ReverseQueryIndex.
|
|
365
368
|
*/
|
|
366
369
|
processChange(mapName, map, changeKey, changeRecord, oldRecord) {
|
|
367
370
|
const index = this.indexes.get(mapName);
|
|
368
371
|
if (!index) return;
|
|
369
372
|
const newVal = this.extractValue(changeRecord);
|
|
370
373
|
const oldVal = this.extractValue(oldRecord);
|
|
374
|
+
if (map instanceof IndexedLWWMap) {
|
|
375
|
+
this.processChangeWithStandingQuery(mapName, map, changeKey, newVal, oldVal);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
this.processChangeWithReverseIndex(mapName, map, changeKey, newVal, oldVal, index);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Process change using IndexedLWWMap's StandingQueryRegistry.
|
|
382
|
+
* O(1) detection of affected queries.
|
|
383
|
+
*/
|
|
384
|
+
processChangeWithStandingQuery(mapName, map, changeKey, newVal, oldVal) {
|
|
385
|
+
const subs = this.subscriptions.get(mapName);
|
|
386
|
+
if (!subs || subs.size === 0) return;
|
|
387
|
+
const subsByQueryId = /* @__PURE__ */ new Map();
|
|
388
|
+
for (const sub of subs) {
|
|
389
|
+
subsByQueryId.set(sub.id, sub);
|
|
390
|
+
}
|
|
391
|
+
const standingRegistry = map.getStandingQueryRegistry();
|
|
392
|
+
let changes;
|
|
393
|
+
if (oldVal === null || oldVal === void 0) {
|
|
394
|
+
if (newVal !== null && newVal !== void 0) {
|
|
395
|
+
changes = standingRegistry.onRecordAdded(changeKey, newVal);
|
|
396
|
+
} else {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
} else if (newVal === null || newVal === void 0) {
|
|
400
|
+
changes = standingRegistry.onRecordRemoved(changeKey, oldVal);
|
|
401
|
+
} else {
|
|
402
|
+
changes = standingRegistry.onRecordUpdated(changeKey, oldVal, newVal);
|
|
403
|
+
}
|
|
404
|
+
for (const sub of subs) {
|
|
405
|
+
const coreQuery = this.convertToCoreQuery(sub.query);
|
|
406
|
+
if (!coreQuery) {
|
|
407
|
+
this.processSubscriptionFallback(sub, map, changeKey, newVal);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const queryHash = this.hashCoreQuery(coreQuery);
|
|
411
|
+
const change = changes.get(queryHash);
|
|
412
|
+
if (change === "added") {
|
|
413
|
+
sub.previousResultKeys.add(changeKey);
|
|
414
|
+
this.sendUpdate(sub, changeKey, newVal, "UPDATE");
|
|
415
|
+
} else if (change === "removed") {
|
|
416
|
+
sub.previousResultKeys.delete(changeKey);
|
|
417
|
+
this.sendUpdate(sub, changeKey, null, "REMOVE");
|
|
418
|
+
} else if (change === "updated") {
|
|
419
|
+
this.sendUpdate(sub, changeKey, newVal, "UPDATE");
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Process change using legacy ReverseQueryIndex.
|
|
425
|
+
*/
|
|
426
|
+
processChangeWithReverseIndex(mapName, map, changeKey, newVal, oldVal, index) {
|
|
371
427
|
const changedFields = this.getChangedFields(oldVal, newVal);
|
|
372
|
-
if (changedFields !== "ALL" && changedFields.size === 0 &&
|
|
428
|
+
if (changedFields !== "ALL" && changedFields.size === 0 && oldVal && newVal) {
|
|
373
429
|
return;
|
|
374
430
|
}
|
|
375
431
|
const candidates = index.getCandidates(changedFields, oldVal, newVal);
|
|
@@ -411,6 +467,103 @@ var QueryRegistry = class {
|
|
|
411
467
|
sub.previousResultKeys = newResultKeys;
|
|
412
468
|
}
|
|
413
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* Fallback processing for subscriptions that can't use StandingQueryRegistry.
|
|
472
|
+
*/
|
|
473
|
+
processSubscriptionFallback(sub, map, changeKey, newVal) {
|
|
474
|
+
const dummyRecord = {
|
|
475
|
+
value: newVal,
|
|
476
|
+
timestamp: { millis: 0, counter: 0, nodeId: "" }
|
|
477
|
+
};
|
|
478
|
+
const isMatch = newVal !== null && matchesQuery(dummyRecord, sub.query);
|
|
479
|
+
const wasInResult = sub.previousResultKeys.has(changeKey);
|
|
480
|
+
if (isMatch && !wasInResult) {
|
|
481
|
+
sub.previousResultKeys.add(changeKey);
|
|
482
|
+
this.sendUpdate(sub, changeKey, newVal, "UPDATE");
|
|
483
|
+
} else if (!isMatch && wasInResult) {
|
|
484
|
+
sub.previousResultKeys.delete(changeKey);
|
|
485
|
+
this.sendUpdate(sub, changeKey, null, "REMOVE");
|
|
486
|
+
} else if (isMatch && wasInResult) {
|
|
487
|
+
this.sendUpdate(sub, changeKey, newVal, "UPDATE");
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Convert server Query format to core Query format.
|
|
492
|
+
*/
|
|
493
|
+
convertToCoreQuery(query) {
|
|
494
|
+
if (query.predicate) {
|
|
495
|
+
return this.predicateToCoreQuery(query.predicate);
|
|
496
|
+
}
|
|
497
|
+
if (query.where) {
|
|
498
|
+
const conditions = [];
|
|
499
|
+
for (const [attribute, condition] of Object.entries(query.where)) {
|
|
500
|
+
if (typeof condition !== "object" || condition === null) {
|
|
501
|
+
conditions.push({ type: "eq", attribute, value: condition });
|
|
502
|
+
} else {
|
|
503
|
+
for (const [op, value] of Object.entries(condition)) {
|
|
504
|
+
const coreOp = this.convertOperator(op);
|
|
505
|
+
if (coreOp) {
|
|
506
|
+
conditions.push({ type: coreOp, attribute, value });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (conditions.length === 0) return null;
|
|
512
|
+
if (conditions.length === 1) return conditions[0];
|
|
513
|
+
return { type: "and", children: conditions };
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
predicateToCoreQuery(predicate) {
|
|
518
|
+
if (!predicate || !predicate.op) return null;
|
|
519
|
+
switch (predicate.op) {
|
|
520
|
+
case "eq":
|
|
521
|
+
case "neq":
|
|
522
|
+
case "gt":
|
|
523
|
+
case "gte":
|
|
524
|
+
case "lt":
|
|
525
|
+
case "lte":
|
|
526
|
+
return {
|
|
527
|
+
type: predicate.op,
|
|
528
|
+
attribute: predicate.attribute,
|
|
529
|
+
value: predicate.value
|
|
530
|
+
};
|
|
531
|
+
case "and":
|
|
532
|
+
case "or":
|
|
533
|
+
if (predicate.children && Array.isArray(predicate.children)) {
|
|
534
|
+
const children = predicate.children.map((c) => this.predicateToCoreQuery(c)).filter((c) => c !== null);
|
|
535
|
+
if (children.length === 0) return null;
|
|
536
|
+
if (children.length === 1) return children[0];
|
|
537
|
+
return { type: predicate.op, children };
|
|
538
|
+
}
|
|
539
|
+
return null;
|
|
540
|
+
case "not":
|
|
541
|
+
if (predicate.children && predicate.children[0]) {
|
|
542
|
+
const child = this.predicateToCoreQuery(predicate.children[0]);
|
|
543
|
+
if (child) {
|
|
544
|
+
return { type: "not", child };
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return null;
|
|
548
|
+
default:
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
convertOperator(op) {
|
|
553
|
+
const mapping = {
|
|
554
|
+
"$eq": "eq",
|
|
555
|
+
"$ne": "neq",
|
|
556
|
+
"$neq": "neq",
|
|
557
|
+
"$gt": "gt",
|
|
558
|
+
"$gte": "gte",
|
|
559
|
+
"$lt": "lt",
|
|
560
|
+
"$lte": "lte"
|
|
561
|
+
};
|
|
562
|
+
return mapping[op] || null;
|
|
563
|
+
}
|
|
564
|
+
hashCoreQuery(query) {
|
|
565
|
+
return JSON.stringify(query);
|
|
566
|
+
}
|
|
414
567
|
extractValue(record) {
|
|
415
568
|
if (!record) return null;
|
|
416
569
|
if (Array.isArray(record)) {
|
|
@@ -6412,118 +6565,1498 @@ var ReplicationPipeline = class extends EventEmitter8 {
|
|
|
6412
6565
|
}
|
|
6413
6566
|
};
|
|
6414
6567
|
|
|
6415
|
-
// src/
|
|
6416
|
-
|
|
6417
|
-
var
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
|
|
6421
|
-
|
|
6422
|
-
this.
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
|
|
6437
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
const
|
|
6442
|
-
this.
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
name: `${config.nodeId}-event-executor`,
|
|
6451
|
-
onReject: (task) => {
|
|
6452
|
-
logger.warn({ nodeId: config.nodeId, key: task.key }, "Event task rejected due to queue capacity");
|
|
6453
|
-
this.metricsService.incEventQueueRejected();
|
|
6568
|
+
// src/handlers/CounterHandler.ts
|
|
6569
|
+
import { PNCounterImpl } from "@topgunbuild/core";
|
|
6570
|
+
var CounterHandler = class {
|
|
6571
|
+
// counterName -> Set<clientId>
|
|
6572
|
+
constructor(nodeId = "server") {
|
|
6573
|
+
this.nodeId = nodeId;
|
|
6574
|
+
this.counters = /* @__PURE__ */ new Map();
|
|
6575
|
+
this.subscriptions = /* @__PURE__ */ new Map();
|
|
6576
|
+
}
|
|
6577
|
+
/**
|
|
6578
|
+
* Get or create a counter by name.
|
|
6579
|
+
*/
|
|
6580
|
+
getOrCreateCounter(name) {
|
|
6581
|
+
let counter = this.counters.get(name);
|
|
6582
|
+
if (!counter) {
|
|
6583
|
+
counter = new PNCounterImpl({ nodeId: this.nodeId });
|
|
6584
|
+
this.counters.set(name, counter);
|
|
6585
|
+
logger.debug({ name }, "Created new counter");
|
|
6586
|
+
}
|
|
6587
|
+
return counter;
|
|
6588
|
+
}
|
|
6589
|
+
/**
|
|
6590
|
+
* Handle COUNTER_REQUEST - client wants initial state.
|
|
6591
|
+
* @returns Response message to send back to client
|
|
6592
|
+
*/
|
|
6593
|
+
handleCounterRequest(clientId, name) {
|
|
6594
|
+
const counter = this.getOrCreateCounter(name);
|
|
6595
|
+
this.subscribe(clientId, name);
|
|
6596
|
+
const state = counter.getState();
|
|
6597
|
+
logger.debug({ clientId, name, value: counter.get() }, "Counter request handled");
|
|
6598
|
+
return {
|
|
6599
|
+
type: "COUNTER_RESPONSE",
|
|
6600
|
+
payload: {
|
|
6601
|
+
name,
|
|
6602
|
+
state: this.stateToObject(state)
|
|
6454
6603
|
}
|
|
6455
|
-
});
|
|
6456
|
-
this.backpressure = new BackpressureRegulator({
|
|
6457
|
-
syncFrequency: config.backpressureSyncFrequency ?? 100,
|
|
6458
|
-
maxPendingOps: config.backpressureMaxPending ?? 1e3,
|
|
6459
|
-
backoffTimeoutMs: config.backpressureBackoffMs ?? 5e3,
|
|
6460
|
-
enabled: config.backpressureEnabled ?? true
|
|
6461
|
-
});
|
|
6462
|
-
this.writeCoalescingEnabled = config.writeCoalescingEnabled ?? true;
|
|
6463
|
-
const preset = coalescingPresets[config.writeCoalescingPreset ?? "highThroughput"];
|
|
6464
|
-
this.writeCoalescingOptions = {
|
|
6465
|
-
maxBatchSize: config.writeCoalescingMaxBatch ?? preset.maxBatchSize,
|
|
6466
|
-
maxDelayMs: config.writeCoalescingMaxDelayMs ?? preset.maxDelayMs,
|
|
6467
|
-
maxBatchBytes: config.writeCoalescingMaxBytes ?? preset.maxBatchBytes
|
|
6468
6604
|
};
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6605
|
+
}
|
|
6606
|
+
/**
|
|
6607
|
+
* Handle COUNTER_SYNC - client sends their state to merge.
|
|
6608
|
+
* @returns Merged state and list of clients to broadcast to
|
|
6609
|
+
*/
|
|
6610
|
+
handleCounterSync(clientId, name, stateObj) {
|
|
6611
|
+
const counter = this.getOrCreateCounter(name);
|
|
6612
|
+
const incomingState = this.objectToState(stateObj);
|
|
6613
|
+
counter.merge(incomingState);
|
|
6614
|
+
const mergedState = counter.getState();
|
|
6615
|
+
const mergedStateObj = this.stateToObject(mergedState);
|
|
6616
|
+
logger.debug(
|
|
6617
|
+
{ clientId, name, value: counter.get() },
|
|
6618
|
+
"Counter sync handled"
|
|
6619
|
+
);
|
|
6620
|
+
this.subscribe(clientId, name);
|
|
6621
|
+
const subscribers = this.subscriptions.get(name) || /* @__PURE__ */ new Set();
|
|
6622
|
+
const broadcastTo = Array.from(subscribers).filter((id) => id !== clientId);
|
|
6623
|
+
return {
|
|
6624
|
+
// Response to the sending client
|
|
6625
|
+
response: {
|
|
6626
|
+
type: "COUNTER_UPDATE",
|
|
6627
|
+
payload: {
|
|
6628
|
+
name,
|
|
6629
|
+
state: mergedStateObj
|
|
6630
|
+
}
|
|
6631
|
+
},
|
|
6632
|
+
// Broadcast to other clients
|
|
6633
|
+
broadcastTo,
|
|
6634
|
+
broadcastMessage: {
|
|
6635
|
+
type: "COUNTER_UPDATE",
|
|
6636
|
+
payload: {
|
|
6637
|
+
name,
|
|
6638
|
+
state: mergedStateObj
|
|
6639
|
+
}
|
|
6640
|
+
}
|
|
6641
|
+
};
|
|
6642
|
+
}
|
|
6643
|
+
/**
|
|
6644
|
+
* Subscribe a client to counter updates.
|
|
6645
|
+
*/
|
|
6646
|
+
subscribe(clientId, counterName) {
|
|
6647
|
+
if (!this.subscriptions.has(counterName)) {
|
|
6648
|
+
this.subscriptions.set(counterName, /* @__PURE__ */ new Set());
|
|
6501
6649
|
}
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
6507
|
-
|
|
6508
|
-
|
|
6650
|
+
this.subscriptions.get(counterName).add(clientId);
|
|
6651
|
+
logger.debug({ clientId, counterName }, "Client subscribed to counter");
|
|
6652
|
+
}
|
|
6653
|
+
/**
|
|
6654
|
+
* Unsubscribe a client from counter updates.
|
|
6655
|
+
*/
|
|
6656
|
+
unsubscribe(clientId, counterName) {
|
|
6657
|
+
const subs = this.subscriptions.get(counterName);
|
|
6658
|
+
if (subs) {
|
|
6659
|
+
subs.delete(clientId);
|
|
6660
|
+
if (subs.size === 0) {
|
|
6661
|
+
this.subscriptions.delete(counterName);
|
|
6662
|
+
}
|
|
6663
|
+
}
|
|
6664
|
+
}
|
|
6665
|
+
/**
|
|
6666
|
+
* Unsubscribe a client from all counters (e.g., on disconnect).
|
|
6667
|
+
*/
|
|
6668
|
+
unsubscribeAll(clientId) {
|
|
6669
|
+
for (const [counterName, subs] of this.subscriptions) {
|
|
6670
|
+
subs.delete(clientId);
|
|
6671
|
+
if (subs.size === 0) {
|
|
6672
|
+
this.subscriptions.delete(counterName);
|
|
6673
|
+
}
|
|
6674
|
+
}
|
|
6675
|
+
logger.debug({ clientId }, "Client unsubscribed from all counters");
|
|
6676
|
+
}
|
|
6677
|
+
/**
|
|
6678
|
+
* Get current counter value (for monitoring/debugging).
|
|
6679
|
+
*/
|
|
6680
|
+
getCounterValue(name) {
|
|
6681
|
+
const counter = this.counters.get(name);
|
|
6682
|
+
return counter ? counter.get() : 0;
|
|
6683
|
+
}
|
|
6684
|
+
/**
|
|
6685
|
+
* Get all counter names.
|
|
6686
|
+
*/
|
|
6687
|
+
getCounterNames() {
|
|
6688
|
+
return Array.from(this.counters.keys());
|
|
6689
|
+
}
|
|
6690
|
+
/**
|
|
6691
|
+
* Get number of subscribers for a counter.
|
|
6692
|
+
*/
|
|
6693
|
+
getSubscriberCount(name) {
|
|
6694
|
+
return this.subscriptions.get(name)?.size || 0;
|
|
6695
|
+
}
|
|
6696
|
+
/**
|
|
6697
|
+
* Convert Map-based state to plain object for serialization.
|
|
6698
|
+
*/
|
|
6699
|
+
stateToObject(state) {
|
|
6700
|
+
return {
|
|
6701
|
+
p: Object.fromEntries(state.positive),
|
|
6702
|
+
n: Object.fromEntries(state.negative)
|
|
6703
|
+
};
|
|
6704
|
+
}
|
|
6705
|
+
/**
|
|
6706
|
+
* Convert plain object to Map-based state.
|
|
6707
|
+
*/
|
|
6708
|
+
objectToState(obj) {
|
|
6709
|
+
return {
|
|
6710
|
+
positive: new Map(Object.entries(obj.p || {})),
|
|
6711
|
+
negative: new Map(Object.entries(obj.n || {}))
|
|
6712
|
+
};
|
|
6713
|
+
}
|
|
6714
|
+
};
|
|
6715
|
+
|
|
6716
|
+
// src/handlers/EntryProcessorHandler.ts
|
|
6717
|
+
import {
|
|
6718
|
+
EntryProcessorDefSchema
|
|
6719
|
+
} from "@topgunbuild/core";
|
|
6720
|
+
|
|
6721
|
+
// src/ProcessorSandbox.ts
|
|
6722
|
+
import {
|
|
6723
|
+
validateProcessorCode
|
|
6724
|
+
} from "@topgunbuild/core";
|
|
6725
|
+
var ivm = null;
|
|
6726
|
+
try {
|
|
6727
|
+
ivm = __require("isolated-vm");
|
|
6728
|
+
} catch {
|
|
6729
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
6730
|
+
if (isProduction) {
|
|
6731
|
+
logger.error(
|
|
6732
|
+
"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"
|
|
6733
|
+
);
|
|
6734
|
+
} else {
|
|
6735
|
+
logger.warn("isolated-vm not available, falling back to less secure VM");
|
|
6736
|
+
}
|
|
6737
|
+
}
|
|
6738
|
+
var DEFAULT_SANDBOX_CONFIG = {
|
|
6739
|
+
memoryLimitMb: 8,
|
|
6740
|
+
timeoutMs: 100,
|
|
6741
|
+
maxCachedIsolates: 100,
|
|
6742
|
+
strictValidation: true
|
|
6743
|
+
};
|
|
6744
|
+
var ProcessorSandbox = class {
|
|
6745
|
+
constructor(config = {}) {
|
|
6746
|
+
this.isolateCache = /* @__PURE__ */ new Map();
|
|
6747
|
+
this.scriptCache = /* @__PURE__ */ new Map();
|
|
6748
|
+
this.fallbackScriptCache = /* @__PURE__ */ new Map();
|
|
6749
|
+
this.disposed = false;
|
|
6750
|
+
this.config = { ...DEFAULT_SANDBOX_CONFIG, ...config };
|
|
6751
|
+
}
|
|
6752
|
+
/**
|
|
6753
|
+
* Execute an entry processor in the sandbox.
|
|
6754
|
+
*
|
|
6755
|
+
* @param processor The processor definition (name, code, args)
|
|
6756
|
+
* @param value The current value for the key (or undefined)
|
|
6757
|
+
* @param key The key being processed
|
|
6758
|
+
* @returns Result containing success status, result, and new value
|
|
6759
|
+
*/
|
|
6760
|
+
async execute(processor, value, key) {
|
|
6761
|
+
if (this.disposed) {
|
|
6762
|
+
return {
|
|
6763
|
+
success: false,
|
|
6764
|
+
error: "Sandbox has been disposed"
|
|
6765
|
+
};
|
|
6766
|
+
}
|
|
6767
|
+
if (this.config.strictValidation) {
|
|
6768
|
+
const validation = validateProcessorCode(processor.code);
|
|
6769
|
+
if (!validation.valid) {
|
|
6770
|
+
return {
|
|
6771
|
+
success: false,
|
|
6772
|
+
error: validation.error
|
|
6773
|
+
};
|
|
6774
|
+
}
|
|
6775
|
+
}
|
|
6776
|
+
if (ivm) {
|
|
6777
|
+
return this.executeInIsolate(processor, value, key);
|
|
6509
6778
|
} else {
|
|
6510
|
-
this.
|
|
6511
|
-
|
|
6512
|
-
|
|
6779
|
+
return this.executeInFallback(processor, value, key);
|
|
6780
|
+
}
|
|
6781
|
+
}
|
|
6782
|
+
/**
|
|
6783
|
+
* Execute processor in isolated-vm (secure production mode).
|
|
6784
|
+
*/
|
|
6785
|
+
async executeInIsolate(processor, value, key) {
|
|
6786
|
+
if (!ivm) {
|
|
6787
|
+
return { success: false, error: "isolated-vm not available" };
|
|
6788
|
+
}
|
|
6789
|
+
const isolate = this.getOrCreateIsolate(processor.name);
|
|
6790
|
+
try {
|
|
6791
|
+
const context = await isolate.createContext();
|
|
6792
|
+
const jail = context.global;
|
|
6793
|
+
await jail.set("global", jail.derefInto());
|
|
6794
|
+
await context.eval(`
|
|
6795
|
+
var value = ${JSON.stringify(value)};
|
|
6796
|
+
var key = ${JSON.stringify(key)};
|
|
6797
|
+
var args = ${JSON.stringify(processor.args)};
|
|
6798
|
+
`);
|
|
6799
|
+
const wrappedCode = `
|
|
6800
|
+
(function() {
|
|
6801
|
+
${processor.code}
|
|
6802
|
+
})()
|
|
6803
|
+
`;
|
|
6804
|
+
const script = await this.getOrCompileScript(
|
|
6805
|
+
processor.name,
|
|
6806
|
+
wrappedCode,
|
|
6807
|
+
isolate
|
|
6808
|
+
);
|
|
6809
|
+
const result = await script.run(context, {
|
|
6810
|
+
timeout: this.config.timeoutMs
|
|
6513
6811
|
});
|
|
6514
|
-
|
|
6515
|
-
|
|
6812
|
+
const parsed = result;
|
|
6813
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
6814
|
+
return {
|
|
6815
|
+
success: false,
|
|
6816
|
+
error: "Processor must return { value, result? } object"
|
|
6817
|
+
};
|
|
6516
6818
|
}
|
|
6819
|
+
return {
|
|
6820
|
+
success: true,
|
|
6821
|
+
result: parsed.result,
|
|
6822
|
+
newValue: parsed.value
|
|
6823
|
+
};
|
|
6824
|
+
} catch (error) {
|
|
6825
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6826
|
+
if (message.includes("Script execution timed out")) {
|
|
6827
|
+
return {
|
|
6828
|
+
success: false,
|
|
6829
|
+
error: "Processor execution timed out"
|
|
6830
|
+
};
|
|
6831
|
+
}
|
|
6832
|
+
return {
|
|
6833
|
+
success: false,
|
|
6834
|
+
error: message
|
|
6835
|
+
};
|
|
6517
6836
|
}
|
|
6518
|
-
|
|
6519
|
-
|
|
6520
|
-
|
|
6521
|
-
|
|
6522
|
-
|
|
6523
|
-
|
|
6524
|
-
|
|
6525
|
-
|
|
6526
|
-
|
|
6837
|
+
}
|
|
6838
|
+
/**
|
|
6839
|
+
* Execute processor in fallback VM (less secure, for development).
|
|
6840
|
+
*/
|
|
6841
|
+
async executeInFallback(processor, value, key) {
|
|
6842
|
+
try {
|
|
6843
|
+
const isResolver = processor.name.startsWith("resolver:");
|
|
6844
|
+
let fn = isResolver ? void 0 : this.fallbackScriptCache.get(processor.name);
|
|
6845
|
+
if (!fn) {
|
|
6846
|
+
const wrappedCode = `
|
|
6847
|
+
return (function(value, key, args) {
|
|
6848
|
+
${processor.code}
|
|
6849
|
+
})
|
|
6850
|
+
`;
|
|
6851
|
+
fn = new Function(wrappedCode)();
|
|
6852
|
+
if (!isResolver) {
|
|
6853
|
+
this.fallbackScriptCache.set(processor.name, fn);
|
|
6854
|
+
}
|
|
6855
|
+
}
|
|
6856
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
6857
|
+
setTimeout(() => reject(new Error("Processor execution timed out")), this.config.timeoutMs);
|
|
6858
|
+
});
|
|
6859
|
+
const executionPromise = Promise.resolve().then(() => fn(value, key, processor.args));
|
|
6860
|
+
const result = await Promise.race([executionPromise, timeoutPromise]);
|
|
6861
|
+
if (typeof result !== "object" || result === null) {
|
|
6862
|
+
return {
|
|
6863
|
+
success: false,
|
|
6864
|
+
error: "Processor must return { value, result? } object"
|
|
6865
|
+
};
|
|
6866
|
+
}
|
|
6867
|
+
return {
|
|
6868
|
+
success: true,
|
|
6869
|
+
result: result.result,
|
|
6870
|
+
newValue: result.value
|
|
6871
|
+
};
|
|
6872
|
+
} catch (error) {
|
|
6873
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6874
|
+
return {
|
|
6875
|
+
success: false,
|
|
6876
|
+
error: message
|
|
6877
|
+
};
|
|
6878
|
+
}
|
|
6879
|
+
}
|
|
6880
|
+
/**
|
|
6881
|
+
* Get or create an isolate for a processor.
|
|
6882
|
+
*/
|
|
6883
|
+
getOrCreateIsolate(name) {
|
|
6884
|
+
if (!ivm) {
|
|
6885
|
+
throw new Error("isolated-vm not available");
|
|
6886
|
+
}
|
|
6887
|
+
let isolate = this.isolateCache.get(name);
|
|
6888
|
+
if (!isolate || isolate.isDisposed) {
|
|
6889
|
+
if (this.isolateCache.size >= this.config.maxCachedIsolates) {
|
|
6890
|
+
const oldest = this.isolateCache.keys().next().value;
|
|
6891
|
+
if (oldest) {
|
|
6892
|
+
const oldIsolate = this.isolateCache.get(oldest);
|
|
6893
|
+
if (oldIsolate && !oldIsolate.isDisposed) {
|
|
6894
|
+
oldIsolate.dispose();
|
|
6895
|
+
}
|
|
6896
|
+
this.isolateCache.delete(oldest);
|
|
6897
|
+
this.scriptCache.delete(oldest);
|
|
6898
|
+
}
|
|
6899
|
+
}
|
|
6900
|
+
isolate = new ivm.Isolate({
|
|
6901
|
+
memoryLimit: this.config.memoryLimitMb
|
|
6902
|
+
});
|
|
6903
|
+
this.isolateCache.set(name, isolate);
|
|
6904
|
+
}
|
|
6905
|
+
return isolate;
|
|
6906
|
+
}
|
|
6907
|
+
/**
|
|
6908
|
+
* Get or compile a script for a processor.
|
|
6909
|
+
*/
|
|
6910
|
+
async getOrCompileScript(name, code, isolate) {
|
|
6911
|
+
let script = this.scriptCache.get(name);
|
|
6912
|
+
if (!script) {
|
|
6913
|
+
script = await isolate.compileScript(code);
|
|
6914
|
+
this.scriptCache.set(name, script);
|
|
6915
|
+
}
|
|
6916
|
+
return script;
|
|
6917
|
+
}
|
|
6918
|
+
/**
|
|
6919
|
+
* Clear script cache for a specific processor (e.g., when code changes).
|
|
6920
|
+
*/
|
|
6921
|
+
clearCache(processorName) {
|
|
6922
|
+
if (processorName) {
|
|
6923
|
+
const isolate = this.isolateCache.get(processorName);
|
|
6924
|
+
if (isolate && !isolate.isDisposed) {
|
|
6925
|
+
isolate.dispose();
|
|
6926
|
+
}
|
|
6927
|
+
this.isolateCache.delete(processorName);
|
|
6928
|
+
this.scriptCache.delete(processorName);
|
|
6929
|
+
this.fallbackScriptCache.delete(processorName);
|
|
6930
|
+
} else {
|
|
6931
|
+
for (const isolate of this.isolateCache.values()) {
|
|
6932
|
+
if (!isolate.isDisposed) {
|
|
6933
|
+
isolate.dispose();
|
|
6934
|
+
}
|
|
6935
|
+
}
|
|
6936
|
+
this.isolateCache.clear();
|
|
6937
|
+
this.scriptCache.clear();
|
|
6938
|
+
this.fallbackScriptCache.clear();
|
|
6939
|
+
}
|
|
6940
|
+
}
|
|
6941
|
+
/**
|
|
6942
|
+
* Check if using secure isolated-vm mode.
|
|
6943
|
+
*/
|
|
6944
|
+
isSecureMode() {
|
|
6945
|
+
return ivm !== null;
|
|
6946
|
+
}
|
|
6947
|
+
/**
|
|
6948
|
+
* Get current cache sizes.
|
|
6949
|
+
*/
|
|
6950
|
+
getCacheStats() {
|
|
6951
|
+
return {
|
|
6952
|
+
isolates: this.isolateCache.size,
|
|
6953
|
+
scripts: this.scriptCache.size,
|
|
6954
|
+
fallbackScripts: this.fallbackScriptCache.size
|
|
6955
|
+
};
|
|
6956
|
+
}
|
|
6957
|
+
/**
|
|
6958
|
+
* Dispose of all isolates and clear caches.
|
|
6959
|
+
*/
|
|
6960
|
+
dispose() {
|
|
6961
|
+
if (this.disposed) return;
|
|
6962
|
+
this.disposed = true;
|
|
6963
|
+
this.clearCache();
|
|
6964
|
+
logger.debug("ProcessorSandbox disposed");
|
|
6965
|
+
}
|
|
6966
|
+
};
|
|
6967
|
+
|
|
6968
|
+
// src/handlers/EntryProcessorHandler.ts
|
|
6969
|
+
var EntryProcessorHandler = class {
|
|
6970
|
+
constructor(config) {
|
|
6971
|
+
this.hlc = config.hlc;
|
|
6972
|
+
this.sandbox = new ProcessorSandbox(config.sandboxConfig);
|
|
6973
|
+
}
|
|
6974
|
+
/**
|
|
6975
|
+
* Execute a processor on a single key atomically.
|
|
6976
|
+
*
|
|
6977
|
+
* @param map The LWWMap to operate on
|
|
6978
|
+
* @param key The key to process
|
|
6979
|
+
* @param processorDef The processor definition (will be validated)
|
|
6980
|
+
* @returns Result with success status, processor result, and new value
|
|
6981
|
+
*/
|
|
6982
|
+
async executeOnKey(map, key, processorDef) {
|
|
6983
|
+
const parseResult = EntryProcessorDefSchema.safeParse(processorDef);
|
|
6984
|
+
if (!parseResult.success) {
|
|
6985
|
+
logger.warn(
|
|
6986
|
+
{ key, error: parseResult.error.message },
|
|
6987
|
+
"Invalid processor definition"
|
|
6988
|
+
);
|
|
6989
|
+
return {
|
|
6990
|
+
result: {
|
|
6991
|
+
success: false,
|
|
6992
|
+
error: `Invalid processor: ${parseResult.error.message}`
|
|
6993
|
+
}
|
|
6994
|
+
};
|
|
6995
|
+
}
|
|
6996
|
+
const processor = parseResult.data;
|
|
6997
|
+
const currentValue = map.get(key);
|
|
6998
|
+
logger.debug(
|
|
6999
|
+
{ key, processor: processor.name, hasValue: currentValue !== void 0 },
|
|
7000
|
+
"Executing entry processor"
|
|
7001
|
+
);
|
|
7002
|
+
const sandboxResult = await this.sandbox.execute(
|
|
7003
|
+
processor,
|
|
7004
|
+
currentValue,
|
|
7005
|
+
key
|
|
7006
|
+
);
|
|
7007
|
+
if (!sandboxResult.success) {
|
|
7008
|
+
logger.warn(
|
|
7009
|
+
{ key, processor: processor.name, error: sandboxResult.error },
|
|
7010
|
+
"Processor execution failed"
|
|
7011
|
+
);
|
|
7012
|
+
return { result: sandboxResult };
|
|
7013
|
+
}
|
|
7014
|
+
let timestamp;
|
|
7015
|
+
if (sandboxResult.newValue !== void 0) {
|
|
7016
|
+
const record = map.set(key, sandboxResult.newValue);
|
|
7017
|
+
timestamp = record.timestamp;
|
|
7018
|
+
logger.debug(
|
|
7019
|
+
{ key, processor: processor.name, timestamp },
|
|
7020
|
+
"Processor updated value"
|
|
7021
|
+
);
|
|
7022
|
+
} else if (currentValue !== void 0) {
|
|
7023
|
+
const tombstone = map.remove(key);
|
|
7024
|
+
timestamp = tombstone.timestamp;
|
|
7025
|
+
logger.debug(
|
|
7026
|
+
{ key, processor: processor.name, timestamp },
|
|
7027
|
+
"Processor deleted value"
|
|
7028
|
+
);
|
|
7029
|
+
}
|
|
7030
|
+
return {
|
|
7031
|
+
result: sandboxResult,
|
|
7032
|
+
timestamp
|
|
7033
|
+
};
|
|
7034
|
+
}
|
|
7035
|
+
/**
|
|
7036
|
+
* Execute a processor on multiple keys.
|
|
7037
|
+
*
|
|
7038
|
+
* Each key is processed sequentially to ensure atomicity per-key.
|
|
7039
|
+
* For parallel execution across keys, use multiple calls.
|
|
7040
|
+
*
|
|
7041
|
+
* @param map The LWWMap to operate on
|
|
7042
|
+
* @param keys The keys to process
|
|
7043
|
+
* @param processorDef The processor definition
|
|
7044
|
+
* @returns Map of key -> result
|
|
7045
|
+
*/
|
|
7046
|
+
async executeOnKeys(map, keys, processorDef) {
|
|
7047
|
+
const results = /* @__PURE__ */ new Map();
|
|
7048
|
+
const timestamps = /* @__PURE__ */ new Map();
|
|
7049
|
+
const parseResult = EntryProcessorDefSchema.safeParse(processorDef);
|
|
7050
|
+
if (!parseResult.success) {
|
|
7051
|
+
const errorResult = {
|
|
7052
|
+
success: false,
|
|
7053
|
+
error: `Invalid processor: ${parseResult.error.message}`
|
|
7054
|
+
};
|
|
7055
|
+
for (const key of keys) {
|
|
7056
|
+
results.set(key, errorResult);
|
|
7057
|
+
}
|
|
7058
|
+
return { results, timestamps };
|
|
7059
|
+
}
|
|
7060
|
+
for (const key of keys) {
|
|
7061
|
+
const { result, timestamp } = await this.executeOnKey(
|
|
7062
|
+
map,
|
|
7063
|
+
key,
|
|
7064
|
+
processorDef
|
|
7065
|
+
);
|
|
7066
|
+
results.set(key, result);
|
|
7067
|
+
if (timestamp) {
|
|
7068
|
+
timestamps.set(key, timestamp);
|
|
7069
|
+
}
|
|
7070
|
+
}
|
|
7071
|
+
return { results, timestamps };
|
|
7072
|
+
}
|
|
7073
|
+
/**
|
|
7074
|
+
* Execute a processor on all entries matching a predicate.
|
|
7075
|
+
*
|
|
7076
|
+
* WARNING: This can be expensive for large maps.
|
|
7077
|
+
*
|
|
7078
|
+
* @param map The LWWMap to operate on
|
|
7079
|
+
* @param processorDef The processor definition
|
|
7080
|
+
* @param predicateCode Optional predicate code to filter entries
|
|
7081
|
+
* @returns Map of key -> result for processed entries
|
|
7082
|
+
*/
|
|
7083
|
+
async executeOnEntries(map, processorDef, predicateCode) {
|
|
7084
|
+
const results = /* @__PURE__ */ new Map();
|
|
7085
|
+
const timestamps = /* @__PURE__ */ new Map();
|
|
7086
|
+
const parseResult = EntryProcessorDefSchema.safeParse(processorDef);
|
|
7087
|
+
if (!parseResult.success) {
|
|
7088
|
+
return { results, timestamps };
|
|
7089
|
+
}
|
|
7090
|
+
const entries = map.entries();
|
|
7091
|
+
for (const [key, value] of entries) {
|
|
7092
|
+
if (predicateCode) {
|
|
7093
|
+
const predicateResult = await this.sandbox.execute(
|
|
7094
|
+
{
|
|
7095
|
+
name: "_predicate",
|
|
7096
|
+
code: `return { value, result: (function() { ${predicateCode} })() };`
|
|
7097
|
+
},
|
|
7098
|
+
value,
|
|
7099
|
+
key
|
|
7100
|
+
);
|
|
7101
|
+
if (!predicateResult.success || !predicateResult.result) {
|
|
7102
|
+
continue;
|
|
7103
|
+
}
|
|
7104
|
+
}
|
|
7105
|
+
const { result, timestamp } = await this.executeOnKey(
|
|
7106
|
+
map,
|
|
7107
|
+
key,
|
|
7108
|
+
processorDef
|
|
7109
|
+
);
|
|
7110
|
+
results.set(key, result);
|
|
7111
|
+
if (timestamp) {
|
|
7112
|
+
timestamps.set(key, timestamp);
|
|
7113
|
+
}
|
|
7114
|
+
}
|
|
7115
|
+
return { results, timestamps };
|
|
7116
|
+
}
|
|
7117
|
+
/**
|
|
7118
|
+
* Check if sandbox is in secure mode (using isolated-vm).
|
|
7119
|
+
*/
|
|
7120
|
+
isSecureMode() {
|
|
7121
|
+
return this.sandbox.isSecureMode();
|
|
7122
|
+
}
|
|
7123
|
+
/**
|
|
7124
|
+
* Get sandbox cache statistics.
|
|
7125
|
+
*/
|
|
7126
|
+
getCacheStats() {
|
|
7127
|
+
return this.sandbox.getCacheStats();
|
|
7128
|
+
}
|
|
7129
|
+
/**
|
|
7130
|
+
* Clear sandbox cache.
|
|
7131
|
+
*/
|
|
7132
|
+
clearCache(processorName) {
|
|
7133
|
+
this.sandbox.clearCache(processorName);
|
|
7134
|
+
}
|
|
7135
|
+
/**
|
|
7136
|
+
* Dispose of the handler and its sandbox.
|
|
7137
|
+
*/
|
|
7138
|
+
dispose() {
|
|
7139
|
+
this.sandbox.dispose();
|
|
7140
|
+
logger.debug("EntryProcessorHandler disposed");
|
|
7141
|
+
}
|
|
7142
|
+
};
|
|
7143
|
+
|
|
7144
|
+
// src/ConflictResolverService.ts
|
|
7145
|
+
import {
|
|
7146
|
+
BuiltInResolvers,
|
|
7147
|
+
ConflictResolverDefSchema,
|
|
7148
|
+
validateResolverCode
|
|
7149
|
+
} from "@topgunbuild/core";
|
|
7150
|
+
var DEFAULT_CONFLICT_RESOLVER_CONFIG = {
|
|
7151
|
+
maxResolversPerMap: 100,
|
|
7152
|
+
enableSandboxedResolvers: true,
|
|
7153
|
+
resolverTimeoutMs: 100
|
|
7154
|
+
};
|
|
7155
|
+
var ConflictResolverService = class {
|
|
7156
|
+
constructor(sandbox, config = {}) {
|
|
7157
|
+
this.resolvers = /* @__PURE__ */ new Map();
|
|
7158
|
+
this.disposed = false;
|
|
7159
|
+
this.sandbox = sandbox;
|
|
7160
|
+
this.config = { ...DEFAULT_CONFLICT_RESOLVER_CONFIG, ...config };
|
|
7161
|
+
}
|
|
7162
|
+
/**
|
|
7163
|
+
* Set callback for merge rejections.
|
|
7164
|
+
*/
|
|
7165
|
+
onRejection(callback) {
|
|
7166
|
+
this.onRejectionCallback = callback;
|
|
7167
|
+
}
|
|
7168
|
+
/**
|
|
7169
|
+
* Register a resolver for a map.
|
|
7170
|
+
*
|
|
7171
|
+
* @param mapName The map this resolver applies to
|
|
7172
|
+
* @param resolver The resolver definition
|
|
7173
|
+
* @param registeredBy Optional client ID that registered this resolver
|
|
7174
|
+
*/
|
|
7175
|
+
register(mapName, resolver, registeredBy) {
|
|
7176
|
+
if (this.disposed) {
|
|
7177
|
+
throw new Error("ConflictResolverService has been disposed");
|
|
7178
|
+
}
|
|
7179
|
+
if (resolver.code) {
|
|
7180
|
+
const parsed = ConflictResolverDefSchema.safeParse({
|
|
7181
|
+
name: resolver.name,
|
|
7182
|
+
code: resolver.code,
|
|
7183
|
+
priority: resolver.priority,
|
|
7184
|
+
keyPattern: resolver.keyPattern
|
|
7185
|
+
});
|
|
7186
|
+
if (!parsed.success) {
|
|
7187
|
+
throw new Error(`Invalid resolver definition: ${parsed.error.message}`);
|
|
7188
|
+
}
|
|
7189
|
+
const validation = validateResolverCode(resolver.code);
|
|
7190
|
+
if (!validation.valid) {
|
|
7191
|
+
throw new Error(`Invalid resolver code: ${validation.error}`);
|
|
7192
|
+
}
|
|
7193
|
+
}
|
|
7194
|
+
const entries = this.resolvers.get(mapName) ?? [];
|
|
7195
|
+
if (entries.length >= this.config.maxResolversPerMap) {
|
|
7196
|
+
throw new Error(
|
|
7197
|
+
`Maximum resolvers per map (${this.config.maxResolversPerMap}) exceeded`
|
|
7198
|
+
);
|
|
7199
|
+
}
|
|
7200
|
+
const filtered = entries.filter((e) => e.resolver.name !== resolver.name);
|
|
7201
|
+
const entry = {
|
|
7202
|
+
resolver,
|
|
7203
|
+
registeredBy
|
|
7204
|
+
};
|
|
7205
|
+
if (resolver.code && !resolver.fn && this.config.enableSandboxedResolvers) {
|
|
7206
|
+
entry.compiledFn = this.compileSandboxed(resolver.name, resolver.code);
|
|
7207
|
+
}
|
|
7208
|
+
filtered.push(entry);
|
|
7209
|
+
filtered.sort(
|
|
7210
|
+
(a, b) => (b.resolver.priority ?? 50) - (a.resolver.priority ?? 50)
|
|
7211
|
+
);
|
|
7212
|
+
this.resolvers.set(mapName, filtered);
|
|
7213
|
+
logger.debug(
|
|
7214
|
+
`Registered resolver '${resolver.name}' for map '${mapName}' with priority ${resolver.priority ?? 50}`
|
|
7215
|
+
);
|
|
7216
|
+
}
|
|
7217
|
+
/**
|
|
7218
|
+
* Unregister a resolver.
|
|
7219
|
+
*
|
|
7220
|
+
* @param mapName The map name
|
|
7221
|
+
* @param resolverName The resolver name to unregister
|
|
7222
|
+
* @param clientId Optional - only unregister if registered by this client
|
|
7223
|
+
*/
|
|
7224
|
+
unregister(mapName, resolverName, clientId) {
|
|
7225
|
+
const entries = this.resolvers.get(mapName);
|
|
7226
|
+
if (!entries) return false;
|
|
7227
|
+
const entryIndex = entries.findIndex(
|
|
7228
|
+
(e) => e.resolver.name === resolverName && (!clientId || e.registeredBy === clientId)
|
|
7229
|
+
);
|
|
7230
|
+
if (entryIndex === -1) return false;
|
|
7231
|
+
entries.splice(entryIndex, 1);
|
|
7232
|
+
if (entries.length === 0) {
|
|
7233
|
+
this.resolvers.delete(mapName);
|
|
7234
|
+
}
|
|
7235
|
+
logger.debug(`Unregistered resolver '${resolverName}' from map '${mapName}'`);
|
|
7236
|
+
return true;
|
|
7237
|
+
}
|
|
7238
|
+
/**
|
|
7239
|
+
* Resolve a merge conflict using registered resolvers.
|
|
7240
|
+
*
|
|
7241
|
+
* @param context The merge context
|
|
7242
|
+
* @returns The merge result
|
|
7243
|
+
*/
|
|
7244
|
+
async resolve(context) {
|
|
7245
|
+
if (this.disposed) {
|
|
7246
|
+
return { action: "accept", value: context.remoteValue };
|
|
7247
|
+
}
|
|
7248
|
+
const entries = this.resolvers.get(context.mapName) ?? [];
|
|
7249
|
+
const allEntries = [
|
|
7250
|
+
...entries,
|
|
7251
|
+
{ resolver: BuiltInResolvers.LWW() }
|
|
7252
|
+
];
|
|
7253
|
+
for (const entry of allEntries) {
|
|
7254
|
+
const { resolver } = entry;
|
|
7255
|
+
if (resolver.keyPattern && !this.matchKeyPattern(context.key, resolver.keyPattern)) {
|
|
7256
|
+
continue;
|
|
7257
|
+
}
|
|
7258
|
+
try {
|
|
7259
|
+
let result;
|
|
7260
|
+
if (resolver.fn) {
|
|
7261
|
+
const fn = resolver.fn;
|
|
7262
|
+
const maybePromise = fn(context);
|
|
7263
|
+
result = maybePromise instanceof Promise ? await maybePromise : maybePromise;
|
|
7264
|
+
} else if (entry.compiledFn) {
|
|
7265
|
+
const compiledFn = entry.compiledFn;
|
|
7266
|
+
result = await compiledFn(context);
|
|
7267
|
+
} else {
|
|
7268
|
+
continue;
|
|
7269
|
+
}
|
|
7270
|
+
if (result.action !== "local") {
|
|
7271
|
+
if (result.action === "reject") {
|
|
7272
|
+
logger.debug(
|
|
7273
|
+
`Resolver '${resolver.name}' rejected merge for key '${context.key}' in map '${context.mapName}': ${result.reason}`
|
|
7274
|
+
);
|
|
7275
|
+
if (this.onRejectionCallback) {
|
|
7276
|
+
this.onRejectionCallback({
|
|
7277
|
+
mapName: context.mapName,
|
|
7278
|
+
key: context.key,
|
|
7279
|
+
attemptedValue: context.remoteValue,
|
|
7280
|
+
reason: result.reason,
|
|
7281
|
+
timestamp: context.remoteTimestamp,
|
|
7282
|
+
nodeId: context.remoteNodeId
|
|
7283
|
+
});
|
|
7284
|
+
}
|
|
7285
|
+
}
|
|
7286
|
+
return result;
|
|
7287
|
+
}
|
|
7288
|
+
} catch (error) {
|
|
7289
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
7290
|
+
logger.error(`Resolver '${resolver.name}' threw error: ${message}`);
|
|
7291
|
+
}
|
|
7292
|
+
}
|
|
7293
|
+
return { action: "accept", value: context.remoteValue };
|
|
7294
|
+
}
|
|
7295
|
+
/**
|
|
7296
|
+
* List registered resolvers.
|
|
7297
|
+
*
|
|
7298
|
+
* @param mapName Optional - filter by map name
|
|
7299
|
+
*/
|
|
7300
|
+
list(mapName) {
|
|
7301
|
+
const result = [];
|
|
7302
|
+
if (mapName) {
|
|
7303
|
+
const entries = this.resolvers.get(mapName) ?? [];
|
|
7304
|
+
for (const entry of entries) {
|
|
7305
|
+
result.push({
|
|
7306
|
+
mapName,
|
|
7307
|
+
name: entry.resolver.name,
|
|
7308
|
+
priority: entry.resolver.priority,
|
|
7309
|
+
keyPattern: entry.resolver.keyPattern,
|
|
7310
|
+
registeredBy: entry.registeredBy
|
|
7311
|
+
});
|
|
7312
|
+
}
|
|
7313
|
+
} else {
|
|
7314
|
+
for (const [map, entries] of this.resolvers.entries()) {
|
|
7315
|
+
for (const entry of entries) {
|
|
7316
|
+
result.push({
|
|
7317
|
+
mapName: map,
|
|
7318
|
+
name: entry.resolver.name,
|
|
7319
|
+
priority: entry.resolver.priority,
|
|
7320
|
+
keyPattern: entry.resolver.keyPattern,
|
|
7321
|
+
registeredBy: entry.registeredBy
|
|
7322
|
+
});
|
|
7323
|
+
}
|
|
7324
|
+
}
|
|
7325
|
+
}
|
|
7326
|
+
return result;
|
|
7327
|
+
}
|
|
7328
|
+
/**
|
|
7329
|
+
* Check if a map has any registered resolvers.
|
|
7330
|
+
*/
|
|
7331
|
+
hasResolvers(mapName) {
|
|
7332
|
+
const entries = this.resolvers.get(mapName);
|
|
7333
|
+
return entries !== void 0 && entries.length > 0;
|
|
7334
|
+
}
|
|
7335
|
+
/**
|
|
7336
|
+
* Get the number of registered resolvers.
|
|
7337
|
+
*/
|
|
7338
|
+
get size() {
|
|
7339
|
+
let count = 0;
|
|
7340
|
+
for (const entries of this.resolvers.values()) {
|
|
7341
|
+
count += entries.length;
|
|
7342
|
+
}
|
|
7343
|
+
return count;
|
|
7344
|
+
}
|
|
7345
|
+
/**
|
|
7346
|
+
* Clear all registered resolvers.
|
|
7347
|
+
*
|
|
7348
|
+
* @param mapName Optional - only clear resolvers for specific map
|
|
7349
|
+
*/
|
|
7350
|
+
clear(mapName) {
|
|
7351
|
+
if (mapName) {
|
|
7352
|
+
this.resolvers.delete(mapName);
|
|
7353
|
+
} else {
|
|
7354
|
+
this.resolvers.clear();
|
|
7355
|
+
}
|
|
7356
|
+
}
|
|
7357
|
+
/**
|
|
7358
|
+
* Clear resolvers registered by a specific client.
|
|
7359
|
+
*/
|
|
7360
|
+
clearByClient(clientId) {
|
|
7361
|
+
let removed = 0;
|
|
7362
|
+
for (const [mapName, entries] of this.resolvers.entries()) {
|
|
7363
|
+
const before = entries.length;
|
|
7364
|
+
const filtered = entries.filter((e) => e.registeredBy !== clientId);
|
|
7365
|
+
removed += before - filtered.length;
|
|
7366
|
+
if (filtered.length === 0) {
|
|
7367
|
+
this.resolvers.delete(mapName);
|
|
7368
|
+
} else if (filtered.length !== before) {
|
|
7369
|
+
this.resolvers.set(mapName, filtered);
|
|
7370
|
+
}
|
|
7371
|
+
}
|
|
7372
|
+
return removed;
|
|
7373
|
+
}
|
|
7374
|
+
/**
|
|
7375
|
+
* Dispose the service.
|
|
7376
|
+
*/
|
|
7377
|
+
dispose() {
|
|
7378
|
+
if (this.disposed) return;
|
|
7379
|
+
this.disposed = true;
|
|
7380
|
+
this.resolvers.clear();
|
|
7381
|
+
logger.debug("ConflictResolverService disposed");
|
|
7382
|
+
}
|
|
7383
|
+
/**
|
|
7384
|
+
* Match a key against a glob-like pattern.
|
|
7385
|
+
* Supports * (any chars) and ? (single char).
|
|
7386
|
+
*/
|
|
7387
|
+
matchKeyPattern(key, pattern) {
|
|
7388
|
+
const regexPattern = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
7389
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
7390
|
+
return regex.test(key);
|
|
7391
|
+
}
|
|
7392
|
+
/**
|
|
7393
|
+
* Compile sandboxed resolver code.
|
|
7394
|
+
*/
|
|
7395
|
+
compileSandboxed(name, code) {
|
|
7396
|
+
return async (ctx) => {
|
|
7397
|
+
const wrappedCode = `
|
|
7398
|
+
const context = {
|
|
7399
|
+
mapName: ${JSON.stringify(ctx.mapName)},
|
|
7400
|
+
key: ${JSON.stringify(ctx.key)},
|
|
7401
|
+
localValue: ${JSON.stringify(ctx.localValue)},
|
|
7402
|
+
remoteValue: ${JSON.stringify(ctx.remoteValue)},
|
|
7403
|
+
localTimestamp: ${JSON.stringify(ctx.localTimestamp)},
|
|
7404
|
+
remoteTimestamp: ${JSON.stringify(ctx.remoteTimestamp)},
|
|
7405
|
+
remoteNodeId: ${JSON.stringify(ctx.remoteNodeId)},
|
|
7406
|
+
auth: ${JSON.stringify(ctx.auth)},
|
|
7407
|
+
};
|
|
7408
|
+
|
|
7409
|
+
function resolve(context) {
|
|
7410
|
+
${code}
|
|
7411
|
+
}
|
|
7412
|
+
|
|
7413
|
+
const result = resolve(context);
|
|
7414
|
+
return { value: result, result };
|
|
7415
|
+
`;
|
|
7416
|
+
const result = await this.sandbox.execute(
|
|
7417
|
+
{
|
|
7418
|
+
name: `resolver:${name}`,
|
|
7419
|
+
code: wrappedCode
|
|
7420
|
+
},
|
|
7421
|
+
null,
|
|
7422
|
+
// value parameter unused for resolvers
|
|
7423
|
+
"resolver"
|
|
7424
|
+
);
|
|
7425
|
+
if (!result.success) {
|
|
7426
|
+
throw new Error(result.error || "Resolver execution failed");
|
|
7427
|
+
}
|
|
7428
|
+
const resolverResult = result.result;
|
|
7429
|
+
if (!resolverResult || typeof resolverResult !== "object") {
|
|
7430
|
+
throw new Error("Resolver must return a result object");
|
|
7431
|
+
}
|
|
7432
|
+
const action = resolverResult.action;
|
|
7433
|
+
if (!["accept", "reject", "merge", "local"].includes(action)) {
|
|
7434
|
+
throw new Error(`Invalid resolver action: ${action}`);
|
|
7435
|
+
}
|
|
7436
|
+
return resolverResult;
|
|
7437
|
+
};
|
|
7438
|
+
}
|
|
7439
|
+
};
|
|
7440
|
+
|
|
7441
|
+
// src/handlers/ConflictResolverHandler.ts
|
|
7442
|
+
var ConflictResolverHandler = class {
|
|
7443
|
+
constructor(config) {
|
|
7444
|
+
this.rejectionListeners = /* @__PURE__ */ new Set();
|
|
7445
|
+
this.nodeId = config.nodeId;
|
|
7446
|
+
this.sandbox = new ProcessorSandbox(config.sandboxConfig);
|
|
7447
|
+
this.resolverService = new ConflictResolverService(
|
|
7448
|
+
this.sandbox,
|
|
7449
|
+
config.resolverConfig
|
|
7450
|
+
);
|
|
7451
|
+
this.resolverService.onRejection((rejection) => {
|
|
7452
|
+
for (const listener of this.rejectionListeners) {
|
|
7453
|
+
try {
|
|
7454
|
+
listener(rejection);
|
|
7455
|
+
} catch (e) {
|
|
7456
|
+
logger.error({ error: e }, "Error in rejection listener");
|
|
7457
|
+
}
|
|
7458
|
+
}
|
|
7459
|
+
});
|
|
7460
|
+
}
|
|
7461
|
+
/**
|
|
7462
|
+
* Register a conflict resolver for a map.
|
|
7463
|
+
*
|
|
7464
|
+
* @param mapName The map name
|
|
7465
|
+
* @param resolver The resolver definition
|
|
7466
|
+
* @param clientId Optional client ID that registered this resolver
|
|
7467
|
+
*/
|
|
7468
|
+
registerResolver(mapName, resolver, clientId) {
|
|
7469
|
+
this.resolverService.register(mapName, resolver, clientId);
|
|
7470
|
+
logger.info(
|
|
7471
|
+
{
|
|
7472
|
+
mapName,
|
|
7473
|
+
resolverName: resolver.name,
|
|
7474
|
+
priority: resolver.priority,
|
|
7475
|
+
clientId
|
|
7476
|
+
},
|
|
7477
|
+
"Resolver registered"
|
|
7478
|
+
);
|
|
7479
|
+
}
|
|
7480
|
+
/**
|
|
7481
|
+
* Unregister a conflict resolver.
|
|
7482
|
+
*
|
|
7483
|
+
* @param mapName The map name
|
|
7484
|
+
* @param resolverName The resolver name
|
|
7485
|
+
* @param clientId Optional - only unregister if registered by this client
|
|
7486
|
+
*/
|
|
7487
|
+
unregisterResolver(mapName, resolverName, clientId) {
|
|
7488
|
+
const removed = this.resolverService.unregister(
|
|
7489
|
+
mapName,
|
|
7490
|
+
resolverName,
|
|
7491
|
+
clientId
|
|
7492
|
+
);
|
|
7493
|
+
if (removed) {
|
|
7494
|
+
logger.info({ mapName, resolverName, clientId }, "Resolver unregistered");
|
|
7495
|
+
}
|
|
7496
|
+
return removed;
|
|
7497
|
+
}
|
|
7498
|
+
/**
|
|
7499
|
+
* List registered resolvers.
|
|
7500
|
+
*
|
|
7501
|
+
* @param mapName Optional - filter by map name
|
|
7502
|
+
*/
|
|
7503
|
+
listResolvers(mapName) {
|
|
7504
|
+
return this.resolverService.list(mapName);
|
|
7505
|
+
}
|
|
7506
|
+
/**
|
|
7507
|
+
* Apply a merge with conflict resolution.
|
|
7508
|
+
*
|
|
7509
|
+
* Deletions (tombstones) are also passed through resolvers to allow
|
|
7510
|
+
* protection via IMMUTABLE, OWNER_ONLY, or similar resolvers.
|
|
7511
|
+
* If no custom resolvers are registered, deletions use standard LWW.
|
|
7512
|
+
*
|
|
7513
|
+
* @param map The LWWMap to merge into
|
|
7514
|
+
* @param mapName The map name (for resolver lookup)
|
|
7515
|
+
* @param key The key being merged
|
|
7516
|
+
* @param record The incoming record
|
|
7517
|
+
* @param remoteNodeId The source node ID
|
|
7518
|
+
* @param auth Optional authentication context
|
|
7519
|
+
*/
|
|
7520
|
+
async mergeWithResolver(map, mapName, key, record, remoteNodeId, auth) {
|
|
7521
|
+
const isDeletion = record.value === null;
|
|
7522
|
+
const localRecord = map.getRecord(key);
|
|
7523
|
+
const context = {
|
|
7524
|
+
mapName,
|
|
7525
|
+
key,
|
|
7526
|
+
localValue: localRecord?.value ?? void 0,
|
|
7527
|
+
// For deletions, remoteValue is null - resolvers can check this
|
|
7528
|
+
remoteValue: record.value,
|
|
7529
|
+
localTimestamp: localRecord?.timestamp,
|
|
7530
|
+
remoteTimestamp: record.timestamp,
|
|
7531
|
+
remoteNodeId,
|
|
7532
|
+
auth,
|
|
7533
|
+
readEntry: (k) => map.get(k)
|
|
7534
|
+
};
|
|
7535
|
+
const result = await this.resolverService.resolve(context);
|
|
7536
|
+
switch (result.action) {
|
|
7537
|
+
case "accept":
|
|
7538
|
+
case "merge": {
|
|
7539
|
+
const finalValue = isDeletion ? null : result.value;
|
|
7540
|
+
const finalRecord = {
|
|
7541
|
+
value: finalValue,
|
|
7542
|
+
timestamp: record.timestamp,
|
|
7543
|
+
ttlMs: record.ttlMs
|
|
7544
|
+
};
|
|
7545
|
+
map.merge(key, finalRecord);
|
|
7546
|
+
return { applied: true, result, record: finalRecord };
|
|
7547
|
+
}
|
|
7548
|
+
case "reject": {
|
|
7549
|
+
const rejection = {
|
|
7550
|
+
mapName,
|
|
7551
|
+
key,
|
|
7552
|
+
attemptedValue: record.value,
|
|
7553
|
+
reason: result.reason,
|
|
7554
|
+
timestamp: record.timestamp,
|
|
7555
|
+
nodeId: remoteNodeId
|
|
7556
|
+
};
|
|
7557
|
+
return { applied: false, result, rejection };
|
|
7558
|
+
}
|
|
7559
|
+
case "local":
|
|
7560
|
+
default:
|
|
7561
|
+
return { applied: false, result };
|
|
7562
|
+
}
|
|
7563
|
+
}
|
|
7564
|
+
/**
|
|
7565
|
+
* Check if a map has custom resolvers registered.
|
|
7566
|
+
*/
|
|
7567
|
+
hasResolvers(mapName) {
|
|
7568
|
+
return this.resolverService.hasResolvers(mapName);
|
|
7569
|
+
}
|
|
7570
|
+
/**
|
|
7571
|
+
* Add a listener for merge rejections.
|
|
7572
|
+
*/
|
|
7573
|
+
onRejection(listener) {
|
|
7574
|
+
this.rejectionListeners.add(listener);
|
|
7575
|
+
return () => this.rejectionListeners.delete(listener);
|
|
7576
|
+
}
|
|
7577
|
+
/**
|
|
7578
|
+
* Clear resolvers registered by a specific client.
|
|
7579
|
+
*/
|
|
7580
|
+
clearByClient(clientId) {
|
|
7581
|
+
return this.resolverService.clearByClient(clientId);
|
|
7582
|
+
}
|
|
7583
|
+
/**
|
|
7584
|
+
* Get the number of registered resolvers.
|
|
7585
|
+
*/
|
|
7586
|
+
get resolverCount() {
|
|
7587
|
+
return this.resolverService.size;
|
|
7588
|
+
}
|
|
7589
|
+
/**
|
|
7590
|
+
* Check if sandbox is in secure mode.
|
|
7591
|
+
*/
|
|
7592
|
+
isSecureMode() {
|
|
7593
|
+
return this.sandbox.isSecureMode();
|
|
7594
|
+
}
|
|
7595
|
+
/**
|
|
7596
|
+
* Dispose of the handler.
|
|
7597
|
+
*/
|
|
7598
|
+
dispose() {
|
|
7599
|
+
this.resolverService.dispose();
|
|
7600
|
+
this.sandbox.dispose();
|
|
7601
|
+
this.rejectionListeners.clear();
|
|
7602
|
+
logger.debug("ConflictResolverHandler disposed");
|
|
7603
|
+
}
|
|
7604
|
+
};
|
|
7605
|
+
|
|
7606
|
+
// src/EventJournalService.ts
|
|
7607
|
+
import {
|
|
7608
|
+
EventJournalImpl,
|
|
7609
|
+
DEFAULT_EVENT_JOURNAL_CONFIG
|
|
7610
|
+
} from "@topgunbuild/core";
|
|
7611
|
+
var DEFAULT_JOURNAL_SERVICE_CONFIG = {
|
|
7612
|
+
...DEFAULT_EVENT_JOURNAL_CONFIG,
|
|
7613
|
+
tableName: "event_journal",
|
|
7614
|
+
persistBatchSize: 100,
|
|
7615
|
+
persistIntervalMs: 1e3
|
|
7616
|
+
};
|
|
7617
|
+
var TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
7618
|
+
function validateTableName(name) {
|
|
7619
|
+
if (!TABLE_NAME_REGEX.test(name)) {
|
|
7620
|
+
throw new Error(
|
|
7621
|
+
`Invalid table name "${name}". Table name must start with a letter or underscore and contain only alphanumeric characters and underscores.`
|
|
7622
|
+
);
|
|
7623
|
+
}
|
|
7624
|
+
}
|
|
7625
|
+
var EventJournalService = class extends EventJournalImpl {
|
|
7626
|
+
constructor(config) {
|
|
7627
|
+
super(config);
|
|
7628
|
+
this.pendingPersist = [];
|
|
7629
|
+
this.isPersisting = false;
|
|
7630
|
+
this.isInitialized = false;
|
|
7631
|
+
this.isLoadingFromStorage = false;
|
|
7632
|
+
this.pool = config.pool;
|
|
7633
|
+
this.tableName = config.tableName ?? DEFAULT_JOURNAL_SERVICE_CONFIG.tableName;
|
|
7634
|
+
this.persistBatchSize = config.persistBatchSize ?? DEFAULT_JOURNAL_SERVICE_CONFIG.persistBatchSize;
|
|
7635
|
+
this.persistIntervalMs = config.persistIntervalMs ?? DEFAULT_JOURNAL_SERVICE_CONFIG.persistIntervalMs;
|
|
7636
|
+
validateTableName(this.tableName);
|
|
7637
|
+
this.subscribe((event) => {
|
|
7638
|
+
if (this.isLoadingFromStorage) return;
|
|
7639
|
+
if (event.sequence >= 0n && this.getConfig().persistent) {
|
|
7640
|
+
this.pendingPersist.push(event);
|
|
7641
|
+
if (this.pendingPersist.length >= this.persistBatchSize) {
|
|
7642
|
+
this.persistToStorage().catch((err) => {
|
|
7643
|
+
logger.error({ err }, "Failed to persist journal events");
|
|
7644
|
+
});
|
|
7645
|
+
}
|
|
7646
|
+
}
|
|
7647
|
+
});
|
|
7648
|
+
this.startPersistTimer();
|
|
7649
|
+
}
|
|
7650
|
+
/**
|
|
7651
|
+
* Initialize the journal service, creating table if needed.
|
|
7652
|
+
*/
|
|
7653
|
+
async initialize() {
|
|
7654
|
+
if (this.isInitialized) return;
|
|
7655
|
+
const client = await this.pool.connect();
|
|
7656
|
+
try {
|
|
7657
|
+
await client.query(`
|
|
7658
|
+
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
7659
|
+
sequence BIGINT PRIMARY KEY,
|
|
7660
|
+
type VARCHAR(10) NOT NULL CHECK (type IN ('PUT', 'UPDATE', 'DELETE')),
|
|
7661
|
+
map_name VARCHAR(255) NOT NULL,
|
|
7662
|
+
key VARCHAR(1024) NOT NULL,
|
|
7663
|
+
value JSONB,
|
|
7664
|
+
previous_value JSONB,
|
|
7665
|
+
timestamp JSONB NOT NULL,
|
|
7666
|
+
node_id VARCHAR(64) NOT NULL,
|
|
7667
|
+
metadata JSONB,
|
|
7668
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
7669
|
+
);
|
|
7670
|
+
`);
|
|
7671
|
+
await client.query(`
|
|
7672
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_map_name
|
|
7673
|
+
ON ${this.tableName}(map_name);
|
|
7674
|
+
`);
|
|
7675
|
+
await client.query(`
|
|
7676
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_key
|
|
7677
|
+
ON ${this.tableName}(map_name, key);
|
|
7678
|
+
`);
|
|
7679
|
+
await client.query(`
|
|
7680
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_created_at
|
|
7681
|
+
ON ${this.tableName}(created_at);
|
|
7682
|
+
`);
|
|
7683
|
+
await client.query(`
|
|
7684
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_node_id
|
|
7685
|
+
ON ${this.tableName}(node_id);
|
|
7686
|
+
`);
|
|
7687
|
+
this.isInitialized = true;
|
|
7688
|
+
logger.info({ tableName: this.tableName }, "EventJournalService initialized");
|
|
7689
|
+
} finally {
|
|
7690
|
+
client.release();
|
|
7691
|
+
}
|
|
7692
|
+
}
|
|
7693
|
+
/**
|
|
7694
|
+
* Persist pending events to PostgreSQL.
|
|
7695
|
+
*/
|
|
7696
|
+
async persistToStorage() {
|
|
7697
|
+
if (this.pendingPersist.length === 0 || this.isPersisting) return;
|
|
7698
|
+
this.isPersisting = true;
|
|
7699
|
+
const batch = this.pendingPersist.splice(0, this.persistBatchSize);
|
|
7700
|
+
try {
|
|
7701
|
+
if (batch.length === 0) return;
|
|
7702
|
+
const values = [];
|
|
7703
|
+
const placeholders = [];
|
|
7704
|
+
batch.forEach((e, i) => {
|
|
7705
|
+
const offset = i * 9;
|
|
7706
|
+
placeholders.push(
|
|
7707
|
+
`($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6}, $${offset + 7}, $${offset + 8}, $${offset + 9})`
|
|
7708
|
+
);
|
|
7709
|
+
values.push(
|
|
7710
|
+
e.sequence.toString(),
|
|
7711
|
+
e.type,
|
|
7712
|
+
e.mapName,
|
|
7713
|
+
e.key,
|
|
7714
|
+
e.value !== void 0 ? JSON.stringify(e.value) : null,
|
|
7715
|
+
e.previousValue !== void 0 ? JSON.stringify(e.previousValue) : null,
|
|
7716
|
+
JSON.stringify(e.timestamp),
|
|
7717
|
+
e.nodeId,
|
|
7718
|
+
e.metadata ? JSON.stringify(e.metadata) : null
|
|
7719
|
+
);
|
|
7720
|
+
});
|
|
7721
|
+
await this.pool.query(
|
|
7722
|
+
`INSERT INTO ${this.tableName}
|
|
7723
|
+
(sequence, type, map_name, key, value, previous_value, timestamp, node_id, metadata)
|
|
7724
|
+
VALUES ${placeholders.join(", ")}
|
|
7725
|
+
ON CONFLICT (sequence) DO NOTHING`,
|
|
7726
|
+
values
|
|
7727
|
+
);
|
|
7728
|
+
logger.debug({ count: batch.length }, "Persisted journal events");
|
|
7729
|
+
} catch (error) {
|
|
7730
|
+
this.pendingPersist.unshift(...batch);
|
|
7731
|
+
throw error;
|
|
7732
|
+
} finally {
|
|
7733
|
+
this.isPersisting = false;
|
|
7734
|
+
}
|
|
7735
|
+
}
|
|
7736
|
+
/**
|
|
7737
|
+
* Load journal events from PostgreSQL on startup.
|
|
7738
|
+
*/
|
|
7739
|
+
async loadFromStorage() {
|
|
7740
|
+
const config = this.getConfig();
|
|
7741
|
+
const result = await this.pool.query(
|
|
7742
|
+
`SELECT sequence, type, map_name, key, value, previous_value, timestamp, node_id, metadata
|
|
7743
|
+
FROM ${this.tableName}
|
|
7744
|
+
ORDER BY sequence DESC
|
|
7745
|
+
LIMIT $1`,
|
|
7746
|
+
[config.capacity]
|
|
7747
|
+
);
|
|
7748
|
+
const events = result.rows.reverse();
|
|
7749
|
+
this.isLoadingFromStorage = true;
|
|
7750
|
+
try {
|
|
7751
|
+
for (const row of events) {
|
|
7752
|
+
this.append({
|
|
7753
|
+
type: row.type,
|
|
7754
|
+
mapName: row.map_name,
|
|
7755
|
+
key: row.key,
|
|
7756
|
+
value: row.value,
|
|
7757
|
+
previousValue: row.previous_value,
|
|
7758
|
+
timestamp: typeof row.timestamp === "string" ? JSON.parse(row.timestamp) : row.timestamp,
|
|
7759
|
+
nodeId: row.node_id,
|
|
7760
|
+
metadata: row.metadata
|
|
7761
|
+
});
|
|
7762
|
+
}
|
|
7763
|
+
} finally {
|
|
7764
|
+
this.isLoadingFromStorage = false;
|
|
7765
|
+
}
|
|
7766
|
+
logger.info({ count: events.length }, "Loaded journal events from storage");
|
|
7767
|
+
}
|
|
7768
|
+
/**
|
|
7769
|
+
* Export events as NDJSON stream.
|
|
7770
|
+
*/
|
|
7771
|
+
exportStream(options = {}) {
|
|
7772
|
+
const self = this;
|
|
7773
|
+
return new ReadableStream({
|
|
7774
|
+
start(controller) {
|
|
7775
|
+
const startSeq = options.fromSequence ?? self.getOldestSequence();
|
|
7776
|
+
const endSeq = options.toSequence ?? self.getLatestSequence();
|
|
7777
|
+
for (let seq = startSeq; seq <= endSeq; seq++) {
|
|
7778
|
+
const events = self.readFrom(seq, 1);
|
|
7779
|
+
if (events.length > 0) {
|
|
7780
|
+
const event = events[0];
|
|
7781
|
+
if (options.mapName && event.mapName !== options.mapName) continue;
|
|
7782
|
+
if (options.types && !options.types.includes(event.type)) continue;
|
|
7783
|
+
const serializable = {
|
|
7784
|
+
...event,
|
|
7785
|
+
sequence: event.sequence.toString()
|
|
7786
|
+
};
|
|
7787
|
+
controller.enqueue(JSON.stringify(serializable) + "\n");
|
|
7788
|
+
}
|
|
7789
|
+
}
|
|
7790
|
+
controller.close();
|
|
7791
|
+
}
|
|
7792
|
+
});
|
|
7793
|
+
}
|
|
7794
|
+
/**
|
|
7795
|
+
* Get events for a specific map.
|
|
7796
|
+
*/
|
|
7797
|
+
getMapEvents(mapName, fromSeq) {
|
|
7798
|
+
const events = this.readFrom(fromSeq ?? this.getOldestSequence(), this.getConfig().capacity);
|
|
7799
|
+
return events.filter((e) => e.mapName === mapName);
|
|
7800
|
+
}
|
|
7801
|
+
/**
|
|
7802
|
+
* Query events from PostgreSQL with filters.
|
|
7803
|
+
*/
|
|
7804
|
+
async queryFromStorage(options = {}) {
|
|
7805
|
+
const conditions = [];
|
|
7806
|
+
const params = [];
|
|
7807
|
+
let paramIndex = 1;
|
|
7808
|
+
if (options.mapName) {
|
|
7809
|
+
conditions.push(`map_name = $${paramIndex++}`);
|
|
7810
|
+
params.push(options.mapName);
|
|
7811
|
+
}
|
|
7812
|
+
if (options.key) {
|
|
7813
|
+
conditions.push(`key = $${paramIndex++}`);
|
|
7814
|
+
params.push(options.key);
|
|
7815
|
+
}
|
|
7816
|
+
if (options.types && options.types.length > 0) {
|
|
7817
|
+
conditions.push(`type = ANY($${paramIndex++})`);
|
|
7818
|
+
params.push(options.types);
|
|
7819
|
+
}
|
|
7820
|
+
if (options.fromSequence !== void 0) {
|
|
7821
|
+
conditions.push(`sequence >= $${paramIndex++}`);
|
|
7822
|
+
params.push(options.fromSequence.toString());
|
|
7823
|
+
}
|
|
7824
|
+
if (options.toSequence !== void 0) {
|
|
7825
|
+
conditions.push(`sequence <= $${paramIndex++}`);
|
|
7826
|
+
params.push(options.toSequence.toString());
|
|
7827
|
+
}
|
|
7828
|
+
if (options.fromDate) {
|
|
7829
|
+
conditions.push(`created_at >= $${paramIndex++}`);
|
|
7830
|
+
params.push(options.fromDate);
|
|
7831
|
+
}
|
|
7832
|
+
if (options.toDate) {
|
|
7833
|
+
conditions.push(`created_at <= $${paramIndex++}`);
|
|
7834
|
+
params.push(options.toDate);
|
|
7835
|
+
}
|
|
7836
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
7837
|
+
const limit = options.limit ?? 100;
|
|
7838
|
+
const offset = options.offset ?? 0;
|
|
7839
|
+
const result = await this.pool.query(
|
|
7840
|
+
`SELECT sequence, type, map_name, key, value, previous_value, timestamp, node_id, metadata
|
|
7841
|
+
FROM ${this.tableName}
|
|
7842
|
+
${whereClause}
|
|
7843
|
+
ORDER BY sequence ASC
|
|
7844
|
+
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
|
7845
|
+
[...params, limit, offset]
|
|
7846
|
+
);
|
|
7847
|
+
return result.rows.map((row) => ({
|
|
7848
|
+
sequence: BigInt(row.sequence),
|
|
7849
|
+
type: row.type,
|
|
7850
|
+
mapName: row.map_name,
|
|
7851
|
+
key: row.key,
|
|
7852
|
+
value: row.value,
|
|
7853
|
+
previousValue: row.previous_value,
|
|
7854
|
+
timestamp: typeof row.timestamp === "string" ? JSON.parse(row.timestamp) : row.timestamp,
|
|
7855
|
+
nodeId: row.node_id,
|
|
7856
|
+
metadata: row.metadata
|
|
7857
|
+
}));
|
|
7858
|
+
}
|
|
7859
|
+
/**
|
|
7860
|
+
* Count events matching filters.
|
|
7861
|
+
*/
|
|
7862
|
+
async countFromStorage(options = {}) {
|
|
7863
|
+
const conditions = [];
|
|
7864
|
+
const params = [];
|
|
7865
|
+
let paramIndex = 1;
|
|
7866
|
+
if (options.mapName) {
|
|
7867
|
+
conditions.push(`map_name = $${paramIndex++}`);
|
|
7868
|
+
params.push(options.mapName);
|
|
7869
|
+
}
|
|
7870
|
+
if (options.types && options.types.length > 0) {
|
|
7871
|
+
conditions.push(`type = ANY($${paramIndex++})`);
|
|
7872
|
+
params.push(options.types);
|
|
7873
|
+
}
|
|
7874
|
+
if (options.fromDate) {
|
|
7875
|
+
conditions.push(`created_at >= $${paramIndex++}`);
|
|
7876
|
+
params.push(options.fromDate);
|
|
7877
|
+
}
|
|
7878
|
+
if (options.toDate) {
|
|
7879
|
+
conditions.push(`created_at <= $${paramIndex++}`);
|
|
7880
|
+
params.push(options.toDate);
|
|
7881
|
+
}
|
|
7882
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
7883
|
+
const result = await this.pool.query(
|
|
7884
|
+
`SELECT COUNT(*) as count FROM ${this.tableName} ${whereClause}`,
|
|
7885
|
+
params
|
|
7886
|
+
);
|
|
7887
|
+
return parseInt(result.rows[0].count, 10);
|
|
7888
|
+
}
|
|
7889
|
+
/**
|
|
7890
|
+
* Cleanup old events based on retention policy.
|
|
7891
|
+
*/
|
|
7892
|
+
async cleanupOldEvents(retentionDays) {
|
|
7893
|
+
const result = await this.pool.query(
|
|
7894
|
+
`DELETE FROM ${this.tableName}
|
|
7895
|
+
WHERE created_at < NOW() - ($1 || ' days')::INTERVAL
|
|
7896
|
+
RETURNING sequence`,
|
|
7897
|
+
[retentionDays]
|
|
7898
|
+
);
|
|
7899
|
+
const count = result.rowCount ?? 0;
|
|
7900
|
+
if (count > 0) {
|
|
7901
|
+
logger.info({ deletedCount: count, retentionDays }, "Cleaned up old journal events");
|
|
7902
|
+
}
|
|
7903
|
+
return count;
|
|
7904
|
+
}
|
|
7905
|
+
/**
|
|
7906
|
+
* Start the periodic persistence timer.
|
|
7907
|
+
*/
|
|
7908
|
+
startPersistTimer() {
|
|
7909
|
+
this.persistTimer = setInterval(() => {
|
|
7910
|
+
if (this.pendingPersist.length > 0) {
|
|
7911
|
+
this.persistToStorage().catch((err) => {
|
|
7912
|
+
logger.error({ err }, "Periodic persist failed");
|
|
7913
|
+
});
|
|
7914
|
+
}
|
|
7915
|
+
}, this.persistIntervalMs);
|
|
7916
|
+
}
|
|
7917
|
+
/**
|
|
7918
|
+
* Stop the periodic persistence timer.
|
|
7919
|
+
*/
|
|
7920
|
+
stopPersistTimer() {
|
|
7921
|
+
if (this.persistTimer) {
|
|
7922
|
+
clearInterval(this.persistTimer);
|
|
7923
|
+
this.persistTimer = void 0;
|
|
7924
|
+
}
|
|
7925
|
+
}
|
|
7926
|
+
/**
|
|
7927
|
+
* Dispose resources and persist remaining events.
|
|
7928
|
+
*/
|
|
7929
|
+
dispose() {
|
|
7930
|
+
this.stopPersistTimer();
|
|
7931
|
+
if (this.pendingPersist.length > 0) {
|
|
7932
|
+
this.persistToStorage().catch((err) => {
|
|
7933
|
+
logger.error({ err }, "Final persist failed on dispose");
|
|
7934
|
+
});
|
|
7935
|
+
}
|
|
7936
|
+
super.dispose();
|
|
7937
|
+
}
|
|
7938
|
+
/**
|
|
7939
|
+
* Get pending persist count (for monitoring).
|
|
7940
|
+
*/
|
|
7941
|
+
getPendingPersistCount() {
|
|
7942
|
+
return this.pendingPersist.length;
|
|
7943
|
+
}
|
|
7944
|
+
};
|
|
7945
|
+
|
|
7946
|
+
// src/ServerCoordinator.ts
|
|
7947
|
+
var GC_INTERVAL_MS = 60 * 60 * 1e3;
|
|
7948
|
+
var GC_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
7949
|
+
var CLIENT_HEARTBEAT_TIMEOUT_MS = 2e4;
|
|
7950
|
+
var CLIENT_HEARTBEAT_CHECK_INTERVAL_MS = 5e3;
|
|
7951
|
+
var ServerCoordinator = class {
|
|
7952
|
+
constructor(config) {
|
|
7953
|
+
this.clients = /* @__PURE__ */ new Map();
|
|
7954
|
+
// Interceptors
|
|
7955
|
+
this.interceptors = [];
|
|
7956
|
+
// In-memory storage (partitioned later)
|
|
7957
|
+
this.maps = /* @__PURE__ */ new Map();
|
|
7958
|
+
this.pendingClusterQueries = /* @__PURE__ */ new Map();
|
|
7959
|
+
// GC Consensus State
|
|
7960
|
+
this.gcReports = /* @__PURE__ */ new Map();
|
|
7961
|
+
// Track map loading state to avoid returning empty results during async load
|
|
7962
|
+
this.mapLoadingPromises = /* @__PURE__ */ new Map();
|
|
7963
|
+
// Track pending batch operations for testing purposes
|
|
7964
|
+
this.pendingBatchOperations = /* @__PURE__ */ new Set();
|
|
7965
|
+
this.journalSubscriptions = /* @__PURE__ */ new Map();
|
|
7966
|
+
this._actualPort = 0;
|
|
7967
|
+
this._actualClusterPort = 0;
|
|
7968
|
+
this._readyPromise = new Promise((resolve) => {
|
|
7969
|
+
this._readyResolve = resolve;
|
|
7970
|
+
});
|
|
7971
|
+
this._nodeId = config.nodeId;
|
|
7972
|
+
this.hlc = new HLC2(config.nodeId);
|
|
7973
|
+
this.storage = config.storage;
|
|
7974
|
+
const rawSecret = config.jwtSecret || process.env.JWT_SECRET || "topgun-secret-dev";
|
|
7975
|
+
this.jwtSecret = rawSecret.replace(/\\n/g, "\n");
|
|
7976
|
+
this.queryRegistry = new QueryRegistry();
|
|
7977
|
+
this.securityManager = new SecurityManager(config.securityPolicies || []);
|
|
7978
|
+
this.interceptors = config.interceptors || [];
|
|
7979
|
+
this.metricsService = new MetricsService();
|
|
7980
|
+
this.eventExecutor = new StripedEventExecutor({
|
|
7981
|
+
stripeCount: config.eventStripeCount ?? 4,
|
|
7982
|
+
queueCapacity: config.eventQueueCapacity ?? 1e4,
|
|
7983
|
+
name: `${config.nodeId}-event-executor`,
|
|
7984
|
+
onReject: (task) => {
|
|
7985
|
+
logger.warn({ nodeId: config.nodeId, key: task.key }, "Event task rejected due to queue capacity");
|
|
7986
|
+
this.metricsService.incEventQueueRejected();
|
|
7987
|
+
}
|
|
7988
|
+
});
|
|
7989
|
+
this.backpressure = new BackpressureRegulator({
|
|
7990
|
+
syncFrequency: config.backpressureSyncFrequency ?? 100,
|
|
7991
|
+
maxPendingOps: config.backpressureMaxPending ?? 1e3,
|
|
7992
|
+
backoffTimeoutMs: config.backpressureBackoffMs ?? 5e3,
|
|
7993
|
+
enabled: config.backpressureEnabled ?? true
|
|
7994
|
+
});
|
|
7995
|
+
this.writeCoalescingEnabled = config.writeCoalescingEnabled ?? true;
|
|
7996
|
+
const preset = coalescingPresets[config.writeCoalescingPreset ?? "highThroughput"];
|
|
7997
|
+
this.writeCoalescingOptions = {
|
|
7998
|
+
maxBatchSize: config.writeCoalescingMaxBatch ?? preset.maxBatchSize,
|
|
7999
|
+
maxDelayMs: config.writeCoalescingMaxDelayMs ?? preset.maxDelayMs,
|
|
8000
|
+
maxBatchBytes: config.writeCoalescingMaxBytes ?? preset.maxBatchBytes
|
|
8001
|
+
};
|
|
8002
|
+
this.eventPayloadPool = createEventPayloadPool({
|
|
8003
|
+
maxSize: 4096,
|
|
8004
|
+
initialSize: 128
|
|
8005
|
+
});
|
|
8006
|
+
this.taskletScheduler = new TaskletScheduler({
|
|
8007
|
+
defaultTimeBudgetMs: 5,
|
|
8008
|
+
maxConcurrent: 20
|
|
8009
|
+
});
|
|
8010
|
+
this.writeAckManager = new WriteAckManager({
|
|
8011
|
+
defaultTimeout: config.writeAckTimeout ?? 5e3
|
|
8012
|
+
});
|
|
8013
|
+
this.rateLimitingEnabled = config.rateLimitingEnabled ?? true;
|
|
8014
|
+
this.rateLimiter = new ConnectionRateLimiter({
|
|
8015
|
+
maxConnectionsPerSecond: config.maxConnectionsPerSecond ?? 100,
|
|
8016
|
+
maxPendingConnections: config.maxPendingConnections ?? 1e3,
|
|
8017
|
+
cooldownMs: 1e3
|
|
8018
|
+
});
|
|
8019
|
+
if (config.workerPoolEnabled) {
|
|
8020
|
+
this.workerPool = new WorkerPool({
|
|
8021
|
+
minWorkers: config.workerPoolConfig?.minWorkers ?? 2,
|
|
8022
|
+
maxWorkers: config.workerPoolConfig?.maxWorkers,
|
|
8023
|
+
taskTimeout: config.workerPoolConfig?.taskTimeout ?? 5e3,
|
|
8024
|
+
idleTimeout: config.workerPoolConfig?.idleTimeout ?? 3e4,
|
|
8025
|
+
autoRestart: config.workerPoolConfig?.autoRestart ?? true
|
|
8026
|
+
});
|
|
8027
|
+
this.merkleWorker = new MerkleWorker(this.workerPool);
|
|
8028
|
+
this.crdtMergeWorker = new CRDTMergeWorker(this.workerPool);
|
|
8029
|
+
this.serializationWorker = new SerializationWorker(this.workerPool);
|
|
8030
|
+
logger.info({
|
|
8031
|
+
minWorkers: config.workerPoolConfig?.minWorkers ?? 2,
|
|
8032
|
+
maxWorkers: config.workerPoolConfig?.maxWorkers ?? "auto"
|
|
8033
|
+
}, "Worker pool initialized for CPU-bound operations");
|
|
8034
|
+
}
|
|
8035
|
+
if (config.tls?.enabled) {
|
|
8036
|
+
const tlsOptions = this.buildTLSOptions(config.tls);
|
|
8037
|
+
this.httpServer = createHttpsServer(tlsOptions, (_req, res) => {
|
|
8038
|
+
res.writeHead(200);
|
|
8039
|
+
res.end("TopGun Server Running (Secure)");
|
|
8040
|
+
});
|
|
8041
|
+
logger.info("TLS enabled for client connections");
|
|
8042
|
+
} else {
|
|
8043
|
+
this.httpServer = createHttpServer((_req, res) => {
|
|
8044
|
+
res.writeHead(200);
|
|
8045
|
+
res.end("TopGun Server Running");
|
|
8046
|
+
});
|
|
8047
|
+
if (process.env.NODE_ENV === "production") {
|
|
8048
|
+
logger.warn("\u26A0\uFE0F TLS is disabled! Client connections are NOT encrypted.");
|
|
8049
|
+
}
|
|
8050
|
+
}
|
|
8051
|
+
const metricsPort = config.metricsPort !== void 0 ? config.metricsPort : 9090;
|
|
8052
|
+
this.metricsServer = createHttpServer(async (req, res) => {
|
|
8053
|
+
if (req.url === "/metrics") {
|
|
8054
|
+
try {
|
|
8055
|
+
res.setHeader("Content-Type", this.metricsService.getContentType());
|
|
8056
|
+
res.end(await this.metricsService.getMetrics());
|
|
8057
|
+
} catch (err) {
|
|
8058
|
+
res.statusCode = 500;
|
|
8059
|
+
res.end("Internal Server Error");
|
|
6527
8060
|
}
|
|
6528
8061
|
} else {
|
|
6529
8062
|
res.statusCode = 404;
|
|
@@ -6600,6 +8133,27 @@ var ServerCoordinator = class {
|
|
|
6600
8133
|
}
|
|
6601
8134
|
}
|
|
6602
8135
|
});
|
|
8136
|
+
this.counterHandler = new CounterHandler(this._nodeId);
|
|
8137
|
+
this.entryProcessorHandler = new EntryProcessorHandler({ hlc: this.hlc });
|
|
8138
|
+
this.conflictResolverHandler = new ConflictResolverHandler({ nodeId: this._nodeId });
|
|
8139
|
+
this.conflictResolverHandler.onRejection((rejection) => {
|
|
8140
|
+
this.notifyMergeRejection(rejection);
|
|
8141
|
+
});
|
|
8142
|
+
if (config.eventJournalEnabled && this.storage && "pool" in this.storage) {
|
|
8143
|
+
const pool = this.storage.pool;
|
|
8144
|
+
this.eventJournalService = new EventJournalService({
|
|
8145
|
+
capacity: 1e4,
|
|
8146
|
+
ttlMs: 0,
|
|
8147
|
+
persistent: true,
|
|
8148
|
+
pool,
|
|
8149
|
+
...config.eventJournalConfig
|
|
8150
|
+
});
|
|
8151
|
+
this.eventJournalService.initialize().then(() => {
|
|
8152
|
+
logger.info("EventJournalService initialized");
|
|
8153
|
+
}).catch((err) => {
|
|
8154
|
+
logger.error({ err }, "Failed to initialize EventJournalService");
|
|
8155
|
+
});
|
|
8156
|
+
}
|
|
6603
8157
|
this.systemManager = new SystemManager(
|
|
6604
8158
|
this.cluster,
|
|
6605
8159
|
this.metricsService,
|
|
@@ -6759,6 +8313,10 @@ var ServerCoordinator = class {
|
|
|
6759
8313
|
this.eventPayloadPool.clear();
|
|
6760
8314
|
this.taskletScheduler.shutdown();
|
|
6761
8315
|
this.writeAckManager.shutdown();
|
|
8316
|
+
this.entryProcessorHandler.dispose();
|
|
8317
|
+
if (this.eventJournalService) {
|
|
8318
|
+
this.eventJournalService.dispose();
|
|
8319
|
+
}
|
|
6762
8320
|
logger.info("Server Coordinator shutdown complete.");
|
|
6763
8321
|
}
|
|
6764
8322
|
async handleConnection(ws) {
|
|
@@ -6865,6 +8423,7 @@ var ServerCoordinator = class {
|
|
|
6865
8423
|
}
|
|
6866
8424
|
this.lockManager.handleClientDisconnect(clientId);
|
|
6867
8425
|
this.topicManager.unsubscribeAll(clientId);
|
|
8426
|
+
this.counterHandler.unsubscribeAll(clientId);
|
|
6868
8427
|
const members = this.cluster.getMembers();
|
|
6869
8428
|
for (const memberId of members) {
|
|
6870
8429
|
if (!this.cluster.isLocal(memberId)) {
|
|
@@ -7120,7 +8679,7 @@ var ServerCoordinator = class {
|
|
|
7120
8679
|
this.metricsService.incOp("GET", message.mapName);
|
|
7121
8680
|
try {
|
|
7122
8681
|
const mapForSync = await this.getMapAsync(message.mapName);
|
|
7123
|
-
if (mapForSync instanceof
|
|
8682
|
+
if (mapForSync instanceof LWWMap3) {
|
|
7124
8683
|
const tree = mapForSync.getMerkleTree();
|
|
7125
8684
|
const rootHash = tree.getRootHash();
|
|
7126
8685
|
client.writer.write({
|
|
@@ -7158,7 +8717,7 @@ var ServerCoordinator = class {
|
|
|
7158
8717
|
const { mapName, path } = message.payload;
|
|
7159
8718
|
try {
|
|
7160
8719
|
const mapForBucket = await this.getMapAsync(mapName);
|
|
7161
|
-
if (mapForBucket instanceof
|
|
8720
|
+
if (mapForBucket instanceof LWWMap3) {
|
|
7162
8721
|
const treeForBucket = mapForBucket.getMerkleTree();
|
|
7163
8722
|
const buckets = treeForBucket.getBuckets(path);
|
|
7164
8723
|
const node = treeForBucket.getNode(path);
|
|
@@ -7287,6 +8846,219 @@ var ServerCoordinator = class {
|
|
|
7287
8846
|
}
|
|
7288
8847
|
break;
|
|
7289
8848
|
}
|
|
8849
|
+
// ============ Phase 5.2: PN Counter Handlers ============
|
|
8850
|
+
case "COUNTER_REQUEST": {
|
|
8851
|
+
const { name } = message.payload;
|
|
8852
|
+
const response = this.counterHandler.handleCounterRequest(client.id, name);
|
|
8853
|
+
client.writer.write(response);
|
|
8854
|
+
logger.debug({ clientId: client.id, name }, "Counter request handled");
|
|
8855
|
+
break;
|
|
8856
|
+
}
|
|
8857
|
+
case "COUNTER_SYNC": {
|
|
8858
|
+
const { name, state } = message.payload;
|
|
8859
|
+
const result = this.counterHandler.handleCounterSync(client.id, name, state);
|
|
8860
|
+
client.writer.write(result.response);
|
|
8861
|
+
for (const targetClientId of result.broadcastTo) {
|
|
8862
|
+
const targetClient = this.clients.get(targetClientId);
|
|
8863
|
+
if (targetClient && targetClient.socket.readyState === WebSocket3.OPEN) {
|
|
8864
|
+
targetClient.writer.write(result.broadcastMessage);
|
|
8865
|
+
}
|
|
8866
|
+
}
|
|
8867
|
+
logger.debug({ clientId: client.id, name, broadcastCount: result.broadcastTo.length }, "Counter sync handled");
|
|
8868
|
+
break;
|
|
8869
|
+
}
|
|
8870
|
+
// ============ Phase 5.03: Entry Processor Handlers ============
|
|
8871
|
+
case "ENTRY_PROCESS": {
|
|
8872
|
+
const { requestId, mapName, key, processor } = message;
|
|
8873
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
8874
|
+
client.writer.write({
|
|
8875
|
+
type: "ENTRY_PROCESS_RESPONSE",
|
|
8876
|
+
requestId,
|
|
8877
|
+
success: false,
|
|
8878
|
+
error: `Access Denied for map ${mapName}`
|
|
8879
|
+
}, true);
|
|
8880
|
+
break;
|
|
8881
|
+
}
|
|
8882
|
+
const entryMap = this.getMap(mapName);
|
|
8883
|
+
const { result, timestamp } = await this.entryProcessorHandler.executeOnKey(
|
|
8884
|
+
entryMap,
|
|
8885
|
+
key,
|
|
8886
|
+
processor
|
|
8887
|
+
);
|
|
8888
|
+
client.writer.write({
|
|
8889
|
+
type: "ENTRY_PROCESS_RESPONSE",
|
|
8890
|
+
requestId,
|
|
8891
|
+
success: result.success,
|
|
8892
|
+
result: result.result,
|
|
8893
|
+
newValue: result.newValue,
|
|
8894
|
+
error: result.error
|
|
8895
|
+
});
|
|
8896
|
+
if (result.success && timestamp) {
|
|
8897
|
+
const record = entryMap.getRecord(key);
|
|
8898
|
+
if (record) {
|
|
8899
|
+
this.queryRegistry.processChange(mapName, entryMap, key, record, void 0);
|
|
8900
|
+
}
|
|
8901
|
+
}
|
|
8902
|
+
logger.debug({
|
|
8903
|
+
clientId: client.id,
|
|
8904
|
+
mapName,
|
|
8905
|
+
key,
|
|
8906
|
+
processor: processor.name,
|
|
8907
|
+
success: result.success
|
|
8908
|
+
}, "Entry processor executed");
|
|
8909
|
+
break;
|
|
8910
|
+
}
|
|
8911
|
+
case "ENTRY_PROCESS_BATCH": {
|
|
8912
|
+
const { requestId, mapName, keys, processor } = message;
|
|
8913
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
8914
|
+
const errorResults = {};
|
|
8915
|
+
for (const key of keys) {
|
|
8916
|
+
errorResults[key] = {
|
|
8917
|
+
success: false,
|
|
8918
|
+
error: `Access Denied for map ${mapName}`
|
|
8919
|
+
};
|
|
8920
|
+
}
|
|
8921
|
+
client.writer.write({
|
|
8922
|
+
type: "ENTRY_PROCESS_BATCH_RESPONSE",
|
|
8923
|
+
requestId,
|
|
8924
|
+
results: errorResults
|
|
8925
|
+
}, true);
|
|
8926
|
+
break;
|
|
8927
|
+
}
|
|
8928
|
+
const batchMap = this.getMap(mapName);
|
|
8929
|
+
const { results, timestamps } = await this.entryProcessorHandler.executeOnKeys(
|
|
8930
|
+
batchMap,
|
|
8931
|
+
keys,
|
|
8932
|
+
processor
|
|
8933
|
+
);
|
|
8934
|
+
const resultsRecord = {};
|
|
8935
|
+
for (const [key, keyResult] of results) {
|
|
8936
|
+
resultsRecord[key] = {
|
|
8937
|
+
success: keyResult.success,
|
|
8938
|
+
result: keyResult.result,
|
|
8939
|
+
newValue: keyResult.newValue,
|
|
8940
|
+
error: keyResult.error
|
|
8941
|
+
};
|
|
8942
|
+
}
|
|
8943
|
+
client.writer.write({
|
|
8944
|
+
type: "ENTRY_PROCESS_BATCH_RESPONSE",
|
|
8945
|
+
requestId,
|
|
8946
|
+
results: resultsRecord
|
|
8947
|
+
});
|
|
8948
|
+
for (const [key] of timestamps) {
|
|
8949
|
+
const record = batchMap.getRecord(key);
|
|
8950
|
+
if (record) {
|
|
8951
|
+
this.queryRegistry.processChange(mapName, batchMap, key, record, void 0);
|
|
8952
|
+
}
|
|
8953
|
+
}
|
|
8954
|
+
logger.debug({
|
|
8955
|
+
clientId: client.id,
|
|
8956
|
+
mapName,
|
|
8957
|
+
keyCount: keys.length,
|
|
8958
|
+
processor: processor.name,
|
|
8959
|
+
successCount: Array.from(results.values()).filter((r) => r.success).length
|
|
8960
|
+
}, "Entry processor batch executed");
|
|
8961
|
+
break;
|
|
8962
|
+
}
|
|
8963
|
+
// ============ Phase 5.05: Conflict Resolver Handlers ============
|
|
8964
|
+
case "REGISTER_RESOLVER": {
|
|
8965
|
+
const { requestId, mapName, resolver } = message;
|
|
8966
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
8967
|
+
client.writer.write({
|
|
8968
|
+
type: "REGISTER_RESOLVER_RESPONSE",
|
|
8969
|
+
requestId,
|
|
8970
|
+
success: false,
|
|
8971
|
+
error: `Access Denied for map ${mapName}`
|
|
8972
|
+
}, true);
|
|
8973
|
+
break;
|
|
8974
|
+
}
|
|
8975
|
+
try {
|
|
8976
|
+
this.conflictResolverHandler.registerResolver(
|
|
8977
|
+
mapName,
|
|
8978
|
+
{
|
|
8979
|
+
name: resolver.name,
|
|
8980
|
+
code: resolver.code,
|
|
8981
|
+
priority: resolver.priority,
|
|
8982
|
+
keyPattern: resolver.keyPattern
|
|
8983
|
+
},
|
|
8984
|
+
client.id
|
|
8985
|
+
);
|
|
8986
|
+
client.writer.write({
|
|
8987
|
+
type: "REGISTER_RESOLVER_RESPONSE",
|
|
8988
|
+
requestId,
|
|
8989
|
+
success: true
|
|
8990
|
+
});
|
|
8991
|
+
logger.info({
|
|
8992
|
+
clientId: client.id,
|
|
8993
|
+
mapName,
|
|
8994
|
+
resolverName: resolver.name,
|
|
8995
|
+
priority: resolver.priority
|
|
8996
|
+
}, "Conflict resolver registered");
|
|
8997
|
+
} catch (err) {
|
|
8998
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
8999
|
+
client.writer.write({
|
|
9000
|
+
type: "REGISTER_RESOLVER_RESPONSE",
|
|
9001
|
+
requestId,
|
|
9002
|
+
success: false,
|
|
9003
|
+
error: errorMessage
|
|
9004
|
+
}, true);
|
|
9005
|
+
logger.warn({
|
|
9006
|
+
clientId: client.id,
|
|
9007
|
+
mapName,
|
|
9008
|
+
error: errorMessage
|
|
9009
|
+
}, "Failed to register conflict resolver");
|
|
9010
|
+
}
|
|
9011
|
+
break;
|
|
9012
|
+
}
|
|
9013
|
+
case "UNREGISTER_RESOLVER": {
|
|
9014
|
+
const { requestId, mapName, resolverName } = message;
|
|
9015
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
9016
|
+
client.writer.write({
|
|
9017
|
+
type: "UNREGISTER_RESOLVER_RESPONSE",
|
|
9018
|
+
requestId,
|
|
9019
|
+
success: false,
|
|
9020
|
+
error: `Access Denied for map ${mapName}`
|
|
9021
|
+
}, true);
|
|
9022
|
+
break;
|
|
9023
|
+
}
|
|
9024
|
+
const removed = this.conflictResolverHandler.unregisterResolver(
|
|
9025
|
+
mapName,
|
|
9026
|
+
resolverName,
|
|
9027
|
+
client.id
|
|
9028
|
+
);
|
|
9029
|
+
client.writer.write({
|
|
9030
|
+
type: "UNREGISTER_RESOLVER_RESPONSE",
|
|
9031
|
+
requestId,
|
|
9032
|
+
success: removed,
|
|
9033
|
+
error: removed ? void 0 : "Resolver not found or not owned by this client"
|
|
9034
|
+
});
|
|
9035
|
+
if (removed) {
|
|
9036
|
+
logger.info({
|
|
9037
|
+
clientId: client.id,
|
|
9038
|
+
mapName,
|
|
9039
|
+
resolverName
|
|
9040
|
+
}, "Conflict resolver unregistered");
|
|
9041
|
+
}
|
|
9042
|
+
break;
|
|
9043
|
+
}
|
|
9044
|
+
case "LIST_RESOLVERS": {
|
|
9045
|
+
const { requestId, mapName } = message;
|
|
9046
|
+
if (mapName && !this.securityManager.checkPermission(client.principal, mapName, "READ")) {
|
|
9047
|
+
client.writer.write({
|
|
9048
|
+
type: "LIST_RESOLVERS_RESPONSE",
|
|
9049
|
+
requestId,
|
|
9050
|
+
resolvers: []
|
|
9051
|
+
});
|
|
9052
|
+
break;
|
|
9053
|
+
}
|
|
9054
|
+
const resolvers = this.conflictResolverHandler.listResolvers(mapName);
|
|
9055
|
+
client.writer.write({
|
|
9056
|
+
type: "LIST_RESOLVERS_RESPONSE",
|
|
9057
|
+
requestId,
|
|
9058
|
+
resolvers
|
|
9059
|
+
});
|
|
9060
|
+
break;
|
|
9061
|
+
}
|
|
7290
9062
|
// ============ Phase 4: Partition Map Request Handler ============
|
|
7291
9063
|
case "PARTITION_MAP_REQUEST": {
|
|
7292
9064
|
const clientVersion = message.payload?.currentVersion ?? 0;
|
|
@@ -7482,6 +9254,92 @@ var ServerCoordinator = class {
|
|
|
7482
9254
|
}
|
|
7483
9255
|
break;
|
|
7484
9256
|
}
|
|
9257
|
+
// === Event Journal Messages (Phase 5.04) ===
|
|
9258
|
+
case "JOURNAL_SUBSCRIBE": {
|
|
9259
|
+
if (!this.eventJournalService) {
|
|
9260
|
+
client.writer.write({
|
|
9261
|
+
type: "ERROR",
|
|
9262
|
+
payload: { code: 503, message: "Event journal not enabled" }
|
|
9263
|
+
}, true);
|
|
9264
|
+
break;
|
|
9265
|
+
}
|
|
9266
|
+
const { requestId, fromSequence, mapName, types } = message;
|
|
9267
|
+
const subscriptionId = requestId;
|
|
9268
|
+
this.journalSubscriptions.set(subscriptionId, {
|
|
9269
|
+
clientId: client.id,
|
|
9270
|
+
mapName,
|
|
9271
|
+
types
|
|
9272
|
+
});
|
|
9273
|
+
const unsubscribe = this.eventJournalService.subscribe(
|
|
9274
|
+
(event) => {
|
|
9275
|
+
if (mapName && event.mapName !== mapName) return;
|
|
9276
|
+
if (types && types.length > 0 && !types.includes(event.type)) return;
|
|
9277
|
+
const clientConn = this.clients.get(client.id);
|
|
9278
|
+
if (!clientConn) {
|
|
9279
|
+
unsubscribe();
|
|
9280
|
+
this.journalSubscriptions.delete(subscriptionId);
|
|
9281
|
+
return;
|
|
9282
|
+
}
|
|
9283
|
+
clientConn.writer.write({
|
|
9284
|
+
type: "JOURNAL_EVENT",
|
|
9285
|
+
event: {
|
|
9286
|
+
sequence: event.sequence.toString(),
|
|
9287
|
+
type: event.type,
|
|
9288
|
+
mapName: event.mapName,
|
|
9289
|
+
key: event.key,
|
|
9290
|
+
value: event.value,
|
|
9291
|
+
previousValue: event.previousValue,
|
|
9292
|
+
timestamp: event.timestamp,
|
|
9293
|
+
nodeId: event.nodeId,
|
|
9294
|
+
metadata: event.metadata
|
|
9295
|
+
}
|
|
9296
|
+
});
|
|
9297
|
+
},
|
|
9298
|
+
fromSequence ? BigInt(fromSequence) : void 0
|
|
9299
|
+
);
|
|
9300
|
+
logger.info({ clientId: client.id, subscriptionId, mapName }, "Journal subscription created");
|
|
9301
|
+
break;
|
|
9302
|
+
}
|
|
9303
|
+
case "JOURNAL_UNSUBSCRIBE": {
|
|
9304
|
+
const { subscriptionId } = message;
|
|
9305
|
+
this.journalSubscriptions.delete(subscriptionId);
|
|
9306
|
+
logger.info({ clientId: client.id, subscriptionId }, "Journal subscription removed");
|
|
9307
|
+
break;
|
|
9308
|
+
}
|
|
9309
|
+
case "JOURNAL_READ": {
|
|
9310
|
+
if (!this.eventJournalService) {
|
|
9311
|
+
client.writer.write({
|
|
9312
|
+
type: "ERROR",
|
|
9313
|
+
payload: { code: 503, message: "Event journal not enabled" }
|
|
9314
|
+
}, true);
|
|
9315
|
+
break;
|
|
9316
|
+
}
|
|
9317
|
+
const { requestId: readReqId, fromSequence: readFromSeq, limit, mapName: readMapName } = message;
|
|
9318
|
+
const startSeq = BigInt(readFromSeq);
|
|
9319
|
+
const eventLimit = limit ?? 100;
|
|
9320
|
+
let events = this.eventJournalService.readFrom(startSeq, eventLimit);
|
|
9321
|
+
if (readMapName) {
|
|
9322
|
+
events = events.filter((e) => e.mapName === readMapName);
|
|
9323
|
+
}
|
|
9324
|
+
const serializedEvents = events.map((e) => ({
|
|
9325
|
+
sequence: e.sequence.toString(),
|
|
9326
|
+
type: e.type,
|
|
9327
|
+
mapName: e.mapName,
|
|
9328
|
+
key: e.key,
|
|
9329
|
+
value: e.value,
|
|
9330
|
+
previousValue: e.previousValue,
|
|
9331
|
+
timestamp: e.timestamp,
|
|
9332
|
+
nodeId: e.nodeId,
|
|
9333
|
+
metadata: e.metadata
|
|
9334
|
+
}));
|
|
9335
|
+
client.writer.write({
|
|
9336
|
+
type: "JOURNAL_READ_RESPONSE",
|
|
9337
|
+
requestId: readReqId,
|
|
9338
|
+
events: serializedEvents,
|
|
9339
|
+
hasMore: events.length === eventLimit
|
|
9340
|
+
});
|
|
9341
|
+
break;
|
|
9342
|
+
}
|
|
7485
9343
|
default:
|
|
7486
9344
|
logger.warn({ type: message.type }, "Unknown message type");
|
|
7487
9345
|
}
|
|
@@ -7495,7 +9353,7 @@ var ServerCoordinator = class {
|
|
|
7495
9353
|
} else if (op.orRecord && op.orRecord.timestamp) {
|
|
7496
9354
|
} else if (op.orTag) {
|
|
7497
9355
|
try {
|
|
7498
|
-
ts =
|
|
9356
|
+
ts = HLC2.parse(op.orTag);
|
|
7499
9357
|
} catch (e) {
|
|
7500
9358
|
}
|
|
7501
9359
|
}
|
|
@@ -7529,6 +9387,39 @@ var ServerCoordinator = class {
|
|
|
7529
9387
|
clientCount: broadcastCount
|
|
7530
9388
|
}, "Broadcast partition map to clients");
|
|
7531
9389
|
}
|
|
9390
|
+
/**
|
|
9391
|
+
* Notify a client about a merge rejection (Phase 5.05).
|
|
9392
|
+
* Finds the client by node ID and sends MERGE_REJECTED message.
|
|
9393
|
+
*/
|
|
9394
|
+
notifyMergeRejection(rejection) {
|
|
9395
|
+
for (const [clientId, client] of this.clients) {
|
|
9396
|
+
if (clientId === rejection.nodeId || rejection.nodeId.includes(clientId)) {
|
|
9397
|
+
client.writer.write({
|
|
9398
|
+
type: "MERGE_REJECTED",
|
|
9399
|
+
mapName: rejection.mapName,
|
|
9400
|
+
key: rejection.key,
|
|
9401
|
+
attemptedValue: rejection.attemptedValue,
|
|
9402
|
+
reason: rejection.reason,
|
|
9403
|
+
timestamp: rejection.timestamp
|
|
9404
|
+
}, true);
|
|
9405
|
+
return;
|
|
9406
|
+
}
|
|
9407
|
+
}
|
|
9408
|
+
const subscribedClientIds = this.queryRegistry.getSubscribedClientIds(rejection.mapName);
|
|
9409
|
+
for (const clientId of subscribedClientIds) {
|
|
9410
|
+
const client = this.clients.get(clientId);
|
|
9411
|
+
if (client) {
|
|
9412
|
+
client.writer.write({
|
|
9413
|
+
type: "MERGE_REJECTED",
|
|
9414
|
+
mapName: rejection.mapName,
|
|
9415
|
+
key: rejection.key,
|
|
9416
|
+
attemptedValue: rejection.attemptedValue,
|
|
9417
|
+
reason: rejection.reason,
|
|
9418
|
+
timestamp: rejection.timestamp
|
|
9419
|
+
});
|
|
9420
|
+
}
|
|
9421
|
+
}
|
|
9422
|
+
}
|
|
7532
9423
|
broadcast(message, excludeClientId) {
|
|
7533
9424
|
const isServerEvent = message.type === "SERVER_EVENT";
|
|
7534
9425
|
if (isServerEvent) {
|
|
@@ -7865,8 +9756,28 @@ var ServerCoordinator = class {
|
|
|
7865
9756
|
}
|
|
7866
9757
|
async executeLocalQuery(mapName, query) {
|
|
7867
9758
|
const map = await this.getMapAsync(mapName);
|
|
9759
|
+
const localQuery = { ...query };
|
|
9760
|
+
delete localQuery.offset;
|
|
9761
|
+
delete localQuery.limit;
|
|
9762
|
+
if (map instanceof IndexedLWWMap2) {
|
|
9763
|
+
const coreQuery = this.convertToCoreQuery(localQuery);
|
|
9764
|
+
if (coreQuery) {
|
|
9765
|
+
const entries = map.queryEntries(coreQuery);
|
|
9766
|
+
return entries.map(([key, value]) => {
|
|
9767
|
+
const record = map.getRecord(key);
|
|
9768
|
+
return { key, value, timestamp: record?.timestamp };
|
|
9769
|
+
});
|
|
9770
|
+
}
|
|
9771
|
+
}
|
|
9772
|
+
if (map instanceof IndexedORMap2) {
|
|
9773
|
+
const coreQuery = this.convertToCoreQuery(localQuery);
|
|
9774
|
+
if (coreQuery) {
|
|
9775
|
+
const results = map.query(coreQuery);
|
|
9776
|
+
return results.map(({ key, value }) => ({ key, value }));
|
|
9777
|
+
}
|
|
9778
|
+
}
|
|
7868
9779
|
const records = /* @__PURE__ */ new Map();
|
|
7869
|
-
if (map instanceof
|
|
9780
|
+
if (map instanceof LWWMap3) {
|
|
7870
9781
|
for (const key of map.allKeys()) {
|
|
7871
9782
|
const rec = map.getRecord(key);
|
|
7872
9783
|
if (rec && rec.value !== null) {
|
|
@@ -7882,11 +9793,89 @@ var ServerCoordinator = class {
|
|
|
7882
9793
|
}
|
|
7883
9794
|
}
|
|
7884
9795
|
}
|
|
7885
|
-
const localQuery = { ...query };
|
|
7886
|
-
delete localQuery.offset;
|
|
7887
|
-
delete localQuery.limit;
|
|
7888
9796
|
return executeQuery(records, localQuery);
|
|
7889
9797
|
}
|
|
9798
|
+
/**
|
|
9799
|
+
* Convert server Query format to core Query format for indexed execution.
|
|
9800
|
+
* Returns null if conversion is not possible (complex queries).
|
|
9801
|
+
*/
|
|
9802
|
+
convertToCoreQuery(query) {
|
|
9803
|
+
if (query.predicate) {
|
|
9804
|
+
return this.predicateToCoreQuery(query.predicate);
|
|
9805
|
+
}
|
|
9806
|
+
if (query.where) {
|
|
9807
|
+
const conditions = [];
|
|
9808
|
+
for (const [attribute, condition] of Object.entries(query.where)) {
|
|
9809
|
+
if (typeof condition !== "object" || condition === null) {
|
|
9810
|
+
conditions.push({ type: "eq", attribute, value: condition });
|
|
9811
|
+
} else {
|
|
9812
|
+
for (const [op, value] of Object.entries(condition)) {
|
|
9813
|
+
const coreOp = this.convertOperator(op);
|
|
9814
|
+
if (coreOp) {
|
|
9815
|
+
conditions.push({ type: coreOp, attribute, value });
|
|
9816
|
+
}
|
|
9817
|
+
}
|
|
9818
|
+
}
|
|
9819
|
+
}
|
|
9820
|
+
if (conditions.length === 0) return null;
|
|
9821
|
+
if (conditions.length === 1) return conditions[0];
|
|
9822
|
+
return { type: "and", children: conditions };
|
|
9823
|
+
}
|
|
9824
|
+
return null;
|
|
9825
|
+
}
|
|
9826
|
+
/**
|
|
9827
|
+
* Convert predicate node to core Query format.
|
|
9828
|
+
*/
|
|
9829
|
+
predicateToCoreQuery(predicate) {
|
|
9830
|
+
if (!predicate || !predicate.op) return null;
|
|
9831
|
+
switch (predicate.op) {
|
|
9832
|
+
case "eq":
|
|
9833
|
+
case "neq":
|
|
9834
|
+
case "gt":
|
|
9835
|
+
case "gte":
|
|
9836
|
+
case "lt":
|
|
9837
|
+
case "lte":
|
|
9838
|
+
return {
|
|
9839
|
+
type: predicate.op,
|
|
9840
|
+
attribute: predicate.attribute,
|
|
9841
|
+
value: predicate.value
|
|
9842
|
+
};
|
|
9843
|
+
case "and":
|
|
9844
|
+
case "or":
|
|
9845
|
+
if (predicate.children && Array.isArray(predicate.children)) {
|
|
9846
|
+
const children = predicate.children.map((c) => this.predicateToCoreQuery(c)).filter((c) => c !== null);
|
|
9847
|
+
if (children.length === 0) return null;
|
|
9848
|
+
if (children.length === 1) return children[0];
|
|
9849
|
+
return { type: predicate.op, children };
|
|
9850
|
+
}
|
|
9851
|
+
return null;
|
|
9852
|
+
case "not":
|
|
9853
|
+
if (predicate.children && predicate.children[0]) {
|
|
9854
|
+
const child = this.predicateToCoreQuery(predicate.children[0]);
|
|
9855
|
+
if (child) {
|
|
9856
|
+
return { type: "not", child };
|
|
9857
|
+
}
|
|
9858
|
+
}
|
|
9859
|
+
return null;
|
|
9860
|
+
default:
|
|
9861
|
+
return null;
|
|
9862
|
+
}
|
|
9863
|
+
}
|
|
9864
|
+
/**
|
|
9865
|
+
* Convert server operator to core query type.
|
|
9866
|
+
*/
|
|
9867
|
+
convertOperator(op) {
|
|
9868
|
+
const mapping = {
|
|
9869
|
+
"$eq": "eq",
|
|
9870
|
+
"$ne": "neq",
|
|
9871
|
+
"$neq": "neq",
|
|
9872
|
+
"$gt": "gt",
|
|
9873
|
+
"$gte": "gte",
|
|
9874
|
+
"$lt": "lt",
|
|
9875
|
+
"$lte": "lte"
|
|
9876
|
+
};
|
|
9877
|
+
return mapping[op] || null;
|
|
9878
|
+
}
|
|
7890
9879
|
finalizeClusterQuery(requestId, timeout = false) {
|
|
7891
9880
|
const pending = this.pendingClusterQueries.get(requestId);
|
|
7892
9881
|
if (!pending) return;
|
|
@@ -7940,10 +9929,10 @@ var ServerCoordinator = class {
|
|
|
7940
9929
|
*
|
|
7941
9930
|
* @returns Event payload for broadcasting (or null if operation failed)
|
|
7942
9931
|
*/
|
|
7943
|
-
applyOpToMap(op) {
|
|
9932
|
+
async applyOpToMap(op, remoteNodeId) {
|
|
7944
9933
|
const typeHint = op.opType === "OR_ADD" || op.opType === "OR_REMOVE" ? "OR" : "LWW";
|
|
7945
9934
|
const map = this.getMap(op.mapName, typeHint);
|
|
7946
|
-
if (typeHint === "OR" && map instanceof
|
|
9935
|
+
if (typeHint === "OR" && map instanceof LWWMap3) {
|
|
7947
9936
|
logger.error({ mapName: op.mapName }, "Map type mismatch: LWWMap but received OR op");
|
|
7948
9937
|
throw new Error("Map type mismatch: LWWMap but received OR op");
|
|
7949
9938
|
}
|
|
@@ -7958,12 +9947,34 @@ var ServerCoordinator = class {
|
|
|
7958
9947
|
mapName: op.mapName,
|
|
7959
9948
|
key: op.key
|
|
7960
9949
|
};
|
|
7961
|
-
if (map instanceof
|
|
9950
|
+
if (map instanceof LWWMap3) {
|
|
7962
9951
|
oldRecord = map.getRecord(op.key);
|
|
7963
|
-
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
|
|
9952
|
+
if (this.conflictResolverHandler.hasResolvers(op.mapName)) {
|
|
9953
|
+
const mergeResult = await this.conflictResolverHandler.mergeWithResolver(
|
|
9954
|
+
map,
|
|
9955
|
+
op.mapName,
|
|
9956
|
+
op.key,
|
|
9957
|
+
op.record,
|
|
9958
|
+
remoteNodeId || this._nodeId
|
|
9959
|
+
);
|
|
9960
|
+
if (!mergeResult.applied) {
|
|
9961
|
+
if (mergeResult.rejection) {
|
|
9962
|
+
logger.debug(
|
|
9963
|
+
{ mapName: op.mapName, key: op.key, reason: mergeResult.rejection.reason },
|
|
9964
|
+
"Merge rejected by resolver"
|
|
9965
|
+
);
|
|
9966
|
+
}
|
|
9967
|
+
return { eventPayload: null, oldRecord, rejected: true };
|
|
9968
|
+
}
|
|
9969
|
+
recordToStore = mergeResult.record;
|
|
9970
|
+
eventPayload.eventType = "UPDATED";
|
|
9971
|
+
eventPayload.record = mergeResult.record;
|
|
9972
|
+
} else {
|
|
9973
|
+
map.merge(op.key, op.record);
|
|
9974
|
+
recordToStore = op.record;
|
|
9975
|
+
eventPayload.eventType = "UPDATED";
|
|
9976
|
+
eventPayload.record = op.record;
|
|
9977
|
+
}
|
|
7967
9978
|
} else if (map instanceof ORMap2) {
|
|
7968
9979
|
oldRecord = map.getRecords(op.key);
|
|
7969
9980
|
if (op.opType === "OR_ADD") {
|
|
@@ -7994,6 +10005,21 @@ var ServerCoordinator = class {
|
|
|
7994
10005
|
});
|
|
7995
10006
|
}
|
|
7996
10007
|
}
|
|
10008
|
+
if (this.eventJournalService) {
|
|
10009
|
+
const isDelete = op.opType === "REMOVE" || op.opType === "OR_REMOVE" || op.record && op.record.value === null;
|
|
10010
|
+
const isNew = !oldRecord || Array.isArray(oldRecord) && oldRecord.length === 0;
|
|
10011
|
+
const journalEventType = isDelete ? "DELETE" : isNew ? "PUT" : "UPDATE";
|
|
10012
|
+
const timestamp = op.record?.timestamp || op.orRecord?.timestamp || this.hlc.now();
|
|
10013
|
+
this.eventJournalService.append({
|
|
10014
|
+
type: journalEventType,
|
|
10015
|
+
mapName: op.mapName,
|
|
10016
|
+
key: op.key,
|
|
10017
|
+
value: op.record?.value ?? op.orRecord?.value,
|
|
10018
|
+
previousValue: oldRecord?.value ?? (Array.isArray(oldRecord) ? oldRecord[0]?.value : void 0),
|
|
10019
|
+
timestamp,
|
|
10020
|
+
nodeId: this._nodeId
|
|
10021
|
+
});
|
|
10022
|
+
}
|
|
7997
10023
|
return { eventPayload, oldRecord };
|
|
7998
10024
|
}
|
|
7999
10025
|
/**
|
|
@@ -8015,7 +10041,10 @@ var ServerCoordinator = class {
|
|
|
8015
10041
|
try {
|
|
8016
10042
|
const op = operation;
|
|
8017
10043
|
logger.debug({ sourceNode, opId, mapName: op.mapName, key: op.key }, "Applying replicated operation");
|
|
8018
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
10044
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, sourceNode);
|
|
10045
|
+
if (rejected || !eventPayload) {
|
|
10046
|
+
return true;
|
|
10047
|
+
}
|
|
8019
10048
|
this.broadcast({
|
|
8020
10049
|
type: "SERVER_EVENT",
|
|
8021
10050
|
payload: eventPayload,
|
|
@@ -8114,7 +10143,10 @@ var ServerCoordinator = class {
|
|
|
8114
10143
|
logger.warn({ err, opId: op.id }, "Interceptor rejected op");
|
|
8115
10144
|
throw err;
|
|
8116
10145
|
}
|
|
8117
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
10146
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, originalSenderId);
|
|
10147
|
+
if (rejected || !eventPayload) {
|
|
10148
|
+
return;
|
|
10149
|
+
}
|
|
8118
10150
|
if (this.replicationPipeline && !fromCluster) {
|
|
8119
10151
|
const opId = op.id || `${op.mapName}:${op.key}:${Date.now()}`;
|
|
8120
10152
|
this.replicationPipeline.replicate(op, opId, op.key).catch((err) => {
|
|
@@ -8242,7 +10274,10 @@ var ServerCoordinator = class {
|
|
|
8242
10274
|
logger.warn({ err, opId: op.id }, "Interceptor rejected op in batch");
|
|
8243
10275
|
throw err;
|
|
8244
10276
|
}
|
|
8245
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
10277
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, clientId);
|
|
10278
|
+
if (rejected || !eventPayload) {
|
|
10279
|
+
return;
|
|
10280
|
+
}
|
|
8246
10281
|
if (this.replicationPipeline) {
|
|
8247
10282
|
const opId = op.id || `${op.mapName}:${op.key}:${Date.now()}`;
|
|
8248
10283
|
this.replicationPipeline.replicate(op, opId, op.key).catch((err) => {
|
|
@@ -8256,9 +10291,9 @@ var ServerCoordinator = class {
|
|
|
8256
10291
|
handleClusterEvent(payload) {
|
|
8257
10292
|
const { mapName, key, eventType } = payload;
|
|
8258
10293
|
const map = this.getMap(mapName, eventType === "OR_ADD" || eventType === "OR_REMOVE" ? "OR" : "LWW");
|
|
8259
|
-
const oldRecord = map instanceof
|
|
10294
|
+
const oldRecord = map instanceof LWWMap3 ? map.getRecord(key) : null;
|
|
8260
10295
|
if (this.partitionService.isRelated(key)) {
|
|
8261
|
-
if (map instanceof
|
|
10296
|
+
if (map instanceof LWWMap3 && payload.record) {
|
|
8262
10297
|
map.merge(key, payload.record);
|
|
8263
10298
|
} else if (map instanceof ORMap2) {
|
|
8264
10299
|
if (eventType === "OR_ADD" && payload.orRecord) {
|
|
@@ -8281,7 +10316,7 @@ var ServerCoordinator = class {
|
|
|
8281
10316
|
if (typeHint === "OR") {
|
|
8282
10317
|
map = new ORMap2(this.hlc);
|
|
8283
10318
|
} else {
|
|
8284
|
-
map = new
|
|
10319
|
+
map = new LWWMap3(this.hlc);
|
|
8285
10320
|
}
|
|
8286
10321
|
this.maps.set(name, map);
|
|
8287
10322
|
if (this.storage) {
|
|
@@ -8304,7 +10339,7 @@ var ServerCoordinator = class {
|
|
|
8304
10339
|
this.getMap(name, typeHint);
|
|
8305
10340
|
const loadingPromise = this.mapLoadingPromises.get(name);
|
|
8306
10341
|
const map = this.maps.get(name);
|
|
8307
|
-
const mapSize = map instanceof
|
|
10342
|
+
const mapSize = map instanceof LWWMap3 ? Array.from(map.entries()).length : map instanceof ORMap2 ? map.size : 0;
|
|
8308
10343
|
logger.info({
|
|
8309
10344
|
mapName: name,
|
|
8310
10345
|
mapExisted,
|
|
@@ -8314,7 +10349,7 @@ var ServerCoordinator = class {
|
|
|
8314
10349
|
if (loadingPromise) {
|
|
8315
10350
|
logger.info({ mapName: name }, "[getMapAsync] Waiting for loadMapFromStorage...");
|
|
8316
10351
|
await loadingPromise;
|
|
8317
|
-
const newMapSize = map instanceof
|
|
10352
|
+
const newMapSize = map instanceof LWWMap3 ? Array.from(map.entries()).length : map instanceof ORMap2 ? map.size : 0;
|
|
8318
10353
|
logger.info({ mapName: name, mapSizeAfterLoad: newMapSize }, "[getMapAsync] Load completed");
|
|
8319
10354
|
}
|
|
8320
10355
|
return this.maps.get(name);
|
|
@@ -8340,13 +10375,13 @@ var ServerCoordinator = class {
|
|
|
8340
10375
|
const currentMap = this.maps.get(name);
|
|
8341
10376
|
if (!currentMap) return;
|
|
8342
10377
|
let targetMap = currentMap;
|
|
8343
|
-
if (isOR && currentMap instanceof
|
|
10378
|
+
if (isOR && currentMap instanceof LWWMap3) {
|
|
8344
10379
|
logger.info({ mapName: name }, "Map auto-detected as ORMap. Switching type.");
|
|
8345
10380
|
targetMap = new ORMap2(this.hlc);
|
|
8346
10381
|
this.maps.set(name, targetMap);
|
|
8347
10382
|
} else if (!isOR && currentMap instanceof ORMap2 && typeHint !== "OR") {
|
|
8348
10383
|
logger.info({ mapName: name }, "Map auto-detected as LWWMap. Switching type.");
|
|
8349
|
-
targetMap = new
|
|
10384
|
+
targetMap = new LWWMap3(this.hlc);
|
|
8350
10385
|
this.maps.set(name, targetMap);
|
|
8351
10386
|
}
|
|
8352
10387
|
if (targetMap instanceof ORMap2) {
|
|
@@ -8362,7 +10397,7 @@ var ServerCoordinator = class {
|
|
|
8362
10397
|
}
|
|
8363
10398
|
}
|
|
8364
10399
|
}
|
|
8365
|
-
} else if (targetMap instanceof
|
|
10400
|
+
} else if (targetMap instanceof LWWMap3) {
|
|
8366
10401
|
for (const [key, record] of records) {
|
|
8367
10402
|
if (!record.type) {
|
|
8368
10403
|
targetMap.merge(key, record);
|
|
@@ -8455,7 +10490,7 @@ var ServerCoordinator = class {
|
|
|
8455
10490
|
reportLocalHlc() {
|
|
8456
10491
|
let minHlc = this.hlc.now();
|
|
8457
10492
|
for (const client of this.clients.values()) {
|
|
8458
|
-
if (
|
|
10493
|
+
if (HLC2.compare(client.lastActiveHlc, minHlc) < 0) {
|
|
8459
10494
|
minHlc = client.lastActiveHlc;
|
|
8460
10495
|
}
|
|
8461
10496
|
}
|
|
@@ -8476,7 +10511,7 @@ var ServerCoordinator = class {
|
|
|
8476
10511
|
let globalSafe = this.hlc.now();
|
|
8477
10512
|
let initialized = false;
|
|
8478
10513
|
for (const ts of this.gcReports.values()) {
|
|
8479
|
-
if (!initialized ||
|
|
10514
|
+
if (!initialized || HLC2.compare(ts, globalSafe) < 0) {
|
|
8480
10515
|
globalSafe = ts;
|
|
8481
10516
|
initialized = true;
|
|
8482
10517
|
}
|
|
@@ -8511,7 +10546,7 @@ var ServerCoordinator = class {
|
|
|
8511
10546
|
logger.info({ olderThanMillis: olderThan.millis }, "Performing Garbage Collection");
|
|
8512
10547
|
const now = Date.now();
|
|
8513
10548
|
for (const [name, map] of this.maps) {
|
|
8514
|
-
if (map instanceof
|
|
10549
|
+
if (map instanceof LWWMap3) {
|
|
8515
10550
|
for (const key of map.allKeys()) {
|
|
8516
10551
|
const record = map.getRecord(key);
|
|
8517
10552
|
if (record && record.value !== null && record.ttlMs) {
|
|
@@ -8803,7 +10838,13 @@ var ServerCoordinator = class {
|
|
|
8803
10838
|
}
|
|
8804
10839
|
return;
|
|
8805
10840
|
}
|
|
8806
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
10841
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, clientId);
|
|
10842
|
+
if (rejected) {
|
|
10843
|
+
if (op.id) {
|
|
10844
|
+
this.writeAckManager.failPending(op.id, "Rejected by conflict resolver");
|
|
10845
|
+
}
|
|
10846
|
+
return;
|
|
10847
|
+
}
|
|
8807
10848
|
if (op.id) {
|
|
8808
10849
|
this.writeAckManager.notifyLevel(op.id, WriteConcern2.APPLIED);
|
|
8809
10850
|
}
|
|
@@ -8885,9 +10926,9 @@ var ServerCoordinator = class {
|
|
|
8885
10926
|
// src/storage/PostgresAdapter.ts
|
|
8886
10927
|
import { Pool } from "pg";
|
|
8887
10928
|
var DEFAULT_TABLE_NAME = "topgun_maps";
|
|
8888
|
-
var
|
|
8889
|
-
function
|
|
8890
|
-
if (!
|
|
10929
|
+
var TABLE_NAME_REGEX2 = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
10930
|
+
function validateTableName2(name) {
|
|
10931
|
+
if (!TABLE_NAME_REGEX2.test(name)) {
|
|
8891
10932
|
throw new Error(
|
|
8892
10933
|
`Invalid table name "${name}". Table name must start with a letter or underscore and contain only alphanumeric characters and underscores.`
|
|
8893
10934
|
);
|
|
@@ -8901,7 +10942,7 @@ var PostgresAdapter = class {
|
|
|
8901
10942
|
this.pool = new Pool(configOrPool);
|
|
8902
10943
|
}
|
|
8903
10944
|
const tableName = options?.tableName ?? DEFAULT_TABLE_NAME;
|
|
8904
|
-
|
|
10945
|
+
validateTableName2(tableName);
|
|
8905
10946
|
this.tableName = tableName;
|
|
8906
10947
|
}
|
|
8907
10948
|
async initialize() {
|
|
@@ -9572,24 +11613,477 @@ var ClusterCoordinator = class extends EventEmitter9 {
|
|
|
9572
11613
|
}
|
|
9573
11614
|
}
|
|
9574
11615
|
};
|
|
11616
|
+
|
|
11617
|
+
// src/MapWithResolver.ts
|
|
11618
|
+
import {
|
|
11619
|
+
LWWMap as LWWMap4,
|
|
11620
|
+
HLC as HLC3
|
|
11621
|
+
} from "@topgunbuild/core";
|
|
11622
|
+
var MapWithResolver = class {
|
|
11623
|
+
constructor(config) {
|
|
11624
|
+
this.mapName = config.name;
|
|
11625
|
+
this.hlc = new HLC3(config.nodeId);
|
|
11626
|
+
this.map = new LWWMap4(this.hlc);
|
|
11627
|
+
this.resolverService = config.resolverService;
|
|
11628
|
+
this.onRejection = config.onRejection;
|
|
11629
|
+
}
|
|
11630
|
+
/**
|
|
11631
|
+
* Get the map name.
|
|
11632
|
+
*/
|
|
11633
|
+
get name() {
|
|
11634
|
+
return this.mapName;
|
|
11635
|
+
}
|
|
11636
|
+
/**
|
|
11637
|
+
* Get the underlying LWWMap.
|
|
11638
|
+
*/
|
|
11639
|
+
get rawMap() {
|
|
11640
|
+
return this.map;
|
|
11641
|
+
}
|
|
11642
|
+
/**
|
|
11643
|
+
* Get a value by key.
|
|
11644
|
+
*/
|
|
11645
|
+
get(key) {
|
|
11646
|
+
return this.map.get(key);
|
|
11647
|
+
}
|
|
11648
|
+
/**
|
|
11649
|
+
* Get the full record for a key.
|
|
11650
|
+
*/
|
|
11651
|
+
getRecord(key) {
|
|
11652
|
+
return this.map.getRecord(key);
|
|
11653
|
+
}
|
|
11654
|
+
/**
|
|
11655
|
+
* Get the timestamp for a key.
|
|
11656
|
+
*/
|
|
11657
|
+
getTimestamp(key) {
|
|
11658
|
+
return this.map.getRecord(key)?.timestamp;
|
|
11659
|
+
}
|
|
11660
|
+
/**
|
|
11661
|
+
* Set a value locally (no resolver).
|
|
11662
|
+
* Use for server-initiated writes.
|
|
11663
|
+
*/
|
|
11664
|
+
set(key, value, ttlMs) {
|
|
11665
|
+
return this.map.set(key, value, ttlMs);
|
|
11666
|
+
}
|
|
11667
|
+
/**
|
|
11668
|
+
* Set a value with conflict resolution.
|
|
11669
|
+
* Use for client-initiated writes.
|
|
11670
|
+
*
|
|
11671
|
+
* @param key The key to set
|
|
11672
|
+
* @param value The new value
|
|
11673
|
+
* @param timestamp The client's timestamp
|
|
11674
|
+
* @param remoteNodeId The client's node ID
|
|
11675
|
+
* @param auth Optional authentication context
|
|
11676
|
+
* @returns Result containing applied status and merge result
|
|
11677
|
+
*/
|
|
11678
|
+
async setWithResolver(key, value, timestamp, remoteNodeId, auth) {
|
|
11679
|
+
const context = {
|
|
11680
|
+
mapName: this.mapName,
|
|
11681
|
+
key,
|
|
11682
|
+
localValue: this.map.get(key),
|
|
11683
|
+
remoteValue: value,
|
|
11684
|
+
localTimestamp: this.getTimestamp(key),
|
|
11685
|
+
remoteTimestamp: timestamp,
|
|
11686
|
+
remoteNodeId,
|
|
11687
|
+
auth,
|
|
11688
|
+
readEntry: (k) => this.map.get(k)
|
|
11689
|
+
};
|
|
11690
|
+
const result = await this.resolverService.resolve(context);
|
|
11691
|
+
switch (result.action) {
|
|
11692
|
+
case "accept": {
|
|
11693
|
+
const record2 = {
|
|
11694
|
+
value: result.value,
|
|
11695
|
+
timestamp
|
|
11696
|
+
};
|
|
11697
|
+
this.map.merge(key, record2);
|
|
11698
|
+
return { applied: true, result, record: record2 };
|
|
11699
|
+
}
|
|
11700
|
+
case "merge": {
|
|
11701
|
+
const record2 = {
|
|
11702
|
+
value: result.value,
|
|
11703
|
+
timestamp
|
|
11704
|
+
};
|
|
11705
|
+
this.map.merge(key, record2);
|
|
11706
|
+
return { applied: true, result, record: record2 };
|
|
11707
|
+
}
|
|
11708
|
+
case "reject": {
|
|
11709
|
+
if (this.onRejection) {
|
|
11710
|
+
this.onRejection({
|
|
11711
|
+
mapName: this.mapName,
|
|
11712
|
+
key,
|
|
11713
|
+
attemptedValue: value,
|
|
11714
|
+
reason: result.reason,
|
|
11715
|
+
timestamp,
|
|
11716
|
+
nodeId: remoteNodeId
|
|
11717
|
+
});
|
|
11718
|
+
}
|
|
11719
|
+
return { applied: false, result };
|
|
11720
|
+
}
|
|
11721
|
+
case "local": {
|
|
11722
|
+
return { applied: false, result };
|
|
11723
|
+
}
|
|
11724
|
+
default:
|
|
11725
|
+
const record = {
|
|
11726
|
+
value: result.value ?? value,
|
|
11727
|
+
timestamp
|
|
11728
|
+
};
|
|
11729
|
+
this.map.merge(key, record);
|
|
11730
|
+
return { applied: true, result, record };
|
|
11731
|
+
}
|
|
11732
|
+
}
|
|
11733
|
+
/**
|
|
11734
|
+
* Remove a key.
|
|
11735
|
+
*/
|
|
11736
|
+
remove(key) {
|
|
11737
|
+
return this.map.remove(key);
|
|
11738
|
+
}
|
|
11739
|
+
/**
|
|
11740
|
+
* Standard merge without resolver (for sync operations).
|
|
11741
|
+
*/
|
|
11742
|
+
merge(key, record) {
|
|
11743
|
+
return this.map.merge(key, record);
|
|
11744
|
+
}
|
|
11745
|
+
/**
|
|
11746
|
+
* Merge with resolver support.
|
|
11747
|
+
* Equivalent to setWithResolver but takes a full record.
|
|
11748
|
+
*/
|
|
11749
|
+
async mergeWithResolver(key, record, remoteNodeId, auth) {
|
|
11750
|
+
if (record.value === null) {
|
|
11751
|
+
const applied = this.map.merge(key, record);
|
|
11752
|
+
return {
|
|
11753
|
+
applied,
|
|
11754
|
+
result: applied ? { action: "accept", value: record.value } : { action: "local" },
|
|
11755
|
+
record: applied ? record : void 0
|
|
11756
|
+
};
|
|
11757
|
+
}
|
|
11758
|
+
return this.setWithResolver(
|
|
11759
|
+
key,
|
|
11760
|
+
record.value,
|
|
11761
|
+
record.timestamp,
|
|
11762
|
+
remoteNodeId,
|
|
11763
|
+
auth
|
|
11764
|
+
);
|
|
11765
|
+
}
|
|
11766
|
+
/**
|
|
11767
|
+
* Clear all data.
|
|
11768
|
+
*/
|
|
11769
|
+
clear() {
|
|
11770
|
+
this.map.clear();
|
|
11771
|
+
}
|
|
11772
|
+
/**
|
|
11773
|
+
* Get map size.
|
|
11774
|
+
*/
|
|
11775
|
+
get size() {
|
|
11776
|
+
return this.map.size;
|
|
11777
|
+
}
|
|
11778
|
+
/**
|
|
11779
|
+
* Iterate over entries.
|
|
11780
|
+
*/
|
|
11781
|
+
entries() {
|
|
11782
|
+
return this.map.entries();
|
|
11783
|
+
}
|
|
11784
|
+
/**
|
|
11785
|
+
* Get all keys.
|
|
11786
|
+
*/
|
|
11787
|
+
allKeys() {
|
|
11788
|
+
return this.map.allKeys();
|
|
11789
|
+
}
|
|
11790
|
+
/**
|
|
11791
|
+
* Subscribe to changes.
|
|
11792
|
+
*/
|
|
11793
|
+
onChange(callback) {
|
|
11794
|
+
return this.map.onChange(callback);
|
|
11795
|
+
}
|
|
11796
|
+
/**
|
|
11797
|
+
* Get MerkleTree for sync.
|
|
11798
|
+
*/
|
|
11799
|
+
getMerkleTree() {
|
|
11800
|
+
return this.map.getMerkleTree();
|
|
11801
|
+
}
|
|
11802
|
+
/**
|
|
11803
|
+
* Prune old tombstones.
|
|
11804
|
+
*/
|
|
11805
|
+
prune(olderThan) {
|
|
11806
|
+
return this.map.prune(olderThan);
|
|
11807
|
+
}
|
|
11808
|
+
};
|
|
11809
|
+
|
|
11810
|
+
// src/config/IndexConfig.ts
|
|
11811
|
+
var DEFAULT_INDEX_CONFIG = {
|
|
11812
|
+
autoIndex: false,
|
|
11813
|
+
maxAutoIndexesPerMap: 10,
|
|
11814
|
+
maps: [],
|
|
11815
|
+
logStats: false,
|
|
11816
|
+
statsLogInterval: 6e4
|
|
11817
|
+
};
|
|
11818
|
+
function validateIndexConfig(config) {
|
|
11819
|
+
const errors = [];
|
|
11820
|
+
if (config.maxAutoIndexesPerMap !== void 0) {
|
|
11821
|
+
if (typeof config.maxAutoIndexesPerMap !== "number" || config.maxAutoIndexesPerMap < 1) {
|
|
11822
|
+
errors.push("maxAutoIndexesPerMap must be a positive number");
|
|
11823
|
+
}
|
|
11824
|
+
}
|
|
11825
|
+
if (config.statsLogInterval !== void 0) {
|
|
11826
|
+
if (typeof config.statsLogInterval !== "number" || config.statsLogInterval < 1e3) {
|
|
11827
|
+
errors.push("statsLogInterval must be at least 1000ms");
|
|
11828
|
+
}
|
|
11829
|
+
}
|
|
11830
|
+
if (config.maps) {
|
|
11831
|
+
const mapNames = /* @__PURE__ */ new Set();
|
|
11832
|
+
for (const mapConfig of config.maps) {
|
|
11833
|
+
if (!mapConfig.mapName || typeof mapConfig.mapName !== "string") {
|
|
11834
|
+
errors.push("Each map config must have a valid mapName");
|
|
11835
|
+
continue;
|
|
11836
|
+
}
|
|
11837
|
+
if (mapNames.has(mapConfig.mapName)) {
|
|
11838
|
+
errors.push(`Duplicate map config for: ${mapConfig.mapName}`);
|
|
11839
|
+
}
|
|
11840
|
+
mapNames.add(mapConfig.mapName);
|
|
11841
|
+
if (!Array.isArray(mapConfig.indexes)) {
|
|
11842
|
+
errors.push(`Map ${mapConfig.mapName}: indexes must be an array`);
|
|
11843
|
+
continue;
|
|
11844
|
+
}
|
|
11845
|
+
const attrNames = /* @__PURE__ */ new Set();
|
|
11846
|
+
for (const indexDef of mapConfig.indexes) {
|
|
11847
|
+
if (!indexDef.attribute || typeof indexDef.attribute !== "string") {
|
|
11848
|
+
errors.push(`Map ${mapConfig.mapName}: index must have valid attribute`);
|
|
11849
|
+
continue;
|
|
11850
|
+
}
|
|
11851
|
+
if (!["hash", "navigable"].includes(indexDef.type)) {
|
|
11852
|
+
errors.push(
|
|
11853
|
+
`Map ${mapConfig.mapName}: index type must be 'hash' or 'navigable'`
|
|
11854
|
+
);
|
|
11855
|
+
}
|
|
11856
|
+
if (indexDef.comparator && !["number", "string", "date"].includes(indexDef.comparator)) {
|
|
11857
|
+
errors.push(
|
|
11858
|
+
`Map ${mapConfig.mapName}: comparator must be 'number', 'string', or 'date'`
|
|
11859
|
+
);
|
|
11860
|
+
}
|
|
11861
|
+
const key = `${indexDef.attribute}:${indexDef.type}`;
|
|
11862
|
+
if (attrNames.has(key)) {
|
|
11863
|
+
errors.push(
|
|
11864
|
+
`Map ${mapConfig.mapName}: duplicate ${indexDef.type} index on ${indexDef.attribute}`
|
|
11865
|
+
);
|
|
11866
|
+
}
|
|
11867
|
+
attrNames.add(key);
|
|
11868
|
+
}
|
|
11869
|
+
}
|
|
11870
|
+
}
|
|
11871
|
+
return errors;
|
|
11872
|
+
}
|
|
11873
|
+
function mergeWithDefaults(userConfig) {
|
|
11874
|
+
return {
|
|
11875
|
+
...DEFAULT_INDEX_CONFIG,
|
|
11876
|
+
...userConfig,
|
|
11877
|
+
maps: userConfig.maps ?? DEFAULT_INDEX_CONFIG.maps
|
|
11878
|
+
};
|
|
11879
|
+
}
|
|
11880
|
+
|
|
11881
|
+
// src/config/MapFactory.ts
|
|
11882
|
+
import {
|
|
11883
|
+
LWWMap as LWWMap5,
|
|
11884
|
+
ORMap as ORMap3,
|
|
11885
|
+
IndexedLWWMap as IndexedLWWMap3,
|
|
11886
|
+
IndexedORMap as IndexedORMap3,
|
|
11887
|
+
simpleAttribute
|
|
11888
|
+
} from "@topgunbuild/core";
|
|
11889
|
+
var MapFactory = class {
|
|
11890
|
+
/**
|
|
11891
|
+
* Create a MapFactory.
|
|
11892
|
+
*
|
|
11893
|
+
* @param config - Server index configuration
|
|
11894
|
+
*/
|
|
11895
|
+
constructor(config) {
|
|
11896
|
+
this.config = mergeWithDefaults(config ?? {});
|
|
11897
|
+
this.mapConfigs = /* @__PURE__ */ new Map();
|
|
11898
|
+
for (const mapConfig of this.config.maps ?? []) {
|
|
11899
|
+
this.mapConfigs.set(mapConfig.mapName, mapConfig);
|
|
11900
|
+
}
|
|
11901
|
+
}
|
|
11902
|
+
/**
|
|
11903
|
+
* Create an LWWMap or IndexedLWWMap based on configuration.
|
|
11904
|
+
*
|
|
11905
|
+
* @param mapName - Name of the map
|
|
11906
|
+
* @param hlc - Hybrid Logical Clock instance
|
|
11907
|
+
* @returns LWWMap or IndexedLWWMap depending on configuration
|
|
11908
|
+
*/
|
|
11909
|
+
createLWWMap(mapName, hlc) {
|
|
11910
|
+
const mapConfig = this.mapConfigs.get(mapName);
|
|
11911
|
+
if (!mapConfig || mapConfig.indexes.length === 0) {
|
|
11912
|
+
return new LWWMap5(hlc);
|
|
11913
|
+
}
|
|
11914
|
+
const map = new IndexedLWWMap3(hlc);
|
|
11915
|
+
for (const indexDef of mapConfig.indexes) {
|
|
11916
|
+
this.addIndexToLWWMap(map, indexDef);
|
|
11917
|
+
}
|
|
11918
|
+
return map;
|
|
11919
|
+
}
|
|
11920
|
+
/**
|
|
11921
|
+
* Create an ORMap or IndexedORMap based on configuration.
|
|
11922
|
+
*
|
|
11923
|
+
* @param mapName - Name of the map
|
|
11924
|
+
* @param hlc - Hybrid Logical Clock instance
|
|
11925
|
+
* @returns ORMap or IndexedORMap depending on configuration
|
|
11926
|
+
*/
|
|
11927
|
+
createORMap(mapName, hlc) {
|
|
11928
|
+
const mapConfig = this.mapConfigs.get(mapName);
|
|
11929
|
+
if (!mapConfig || mapConfig.indexes.length === 0) {
|
|
11930
|
+
return new ORMap3(hlc);
|
|
11931
|
+
}
|
|
11932
|
+
const map = new IndexedORMap3(hlc);
|
|
11933
|
+
for (const indexDef of mapConfig.indexes) {
|
|
11934
|
+
this.addIndexToORMap(map, indexDef);
|
|
11935
|
+
}
|
|
11936
|
+
return map;
|
|
11937
|
+
}
|
|
11938
|
+
/**
|
|
11939
|
+
* Add an index to an IndexedLWWMap based on definition.
|
|
11940
|
+
*/
|
|
11941
|
+
addIndexToLWWMap(map, indexDef) {
|
|
11942
|
+
const attribute = this.createAttribute(indexDef.attribute);
|
|
11943
|
+
if (indexDef.type === "hash") {
|
|
11944
|
+
map.addHashIndex(attribute);
|
|
11945
|
+
} else if (indexDef.type === "navigable") {
|
|
11946
|
+
const navAttribute = attribute;
|
|
11947
|
+
const comparator = this.createComparator(indexDef.comparator);
|
|
11948
|
+
map.addNavigableIndex(navAttribute, comparator);
|
|
11949
|
+
}
|
|
11950
|
+
}
|
|
11951
|
+
/**
|
|
11952
|
+
* Add an index to an IndexedORMap based on definition.
|
|
11953
|
+
*/
|
|
11954
|
+
addIndexToORMap(map, indexDef) {
|
|
11955
|
+
const attribute = this.createAttribute(indexDef.attribute);
|
|
11956
|
+
if (indexDef.type === "hash") {
|
|
11957
|
+
map.addHashIndex(attribute);
|
|
11958
|
+
} else if (indexDef.type === "navigable") {
|
|
11959
|
+
const navAttribute = attribute;
|
|
11960
|
+
const comparator = this.createComparator(indexDef.comparator);
|
|
11961
|
+
map.addNavigableIndex(navAttribute, comparator);
|
|
11962
|
+
}
|
|
11963
|
+
}
|
|
11964
|
+
/**
|
|
11965
|
+
* Create an Attribute for extracting values from records.
|
|
11966
|
+
* Supports dot notation for nested paths.
|
|
11967
|
+
*/
|
|
11968
|
+
createAttribute(path) {
|
|
11969
|
+
return simpleAttribute(path, (record) => {
|
|
11970
|
+
return this.getNestedValue(record, path);
|
|
11971
|
+
});
|
|
11972
|
+
}
|
|
11973
|
+
/**
|
|
11974
|
+
* Get a nested value from an object using dot notation.
|
|
11975
|
+
*
|
|
11976
|
+
* @param obj - Object to extract value from
|
|
11977
|
+
* @param path - Dot-notation path (e.g., "user.email")
|
|
11978
|
+
* @returns Value at the path or undefined
|
|
11979
|
+
*/
|
|
11980
|
+
getNestedValue(obj, path) {
|
|
11981
|
+
if (obj === null || obj === void 0) {
|
|
11982
|
+
return void 0;
|
|
11983
|
+
}
|
|
11984
|
+
const parts = path.split(".");
|
|
11985
|
+
let current = obj;
|
|
11986
|
+
for (const part of parts) {
|
|
11987
|
+
if (current === void 0 || current === null) {
|
|
11988
|
+
return void 0;
|
|
11989
|
+
}
|
|
11990
|
+
if (typeof current !== "object") {
|
|
11991
|
+
return void 0;
|
|
11992
|
+
}
|
|
11993
|
+
current = current[part];
|
|
11994
|
+
}
|
|
11995
|
+
return current;
|
|
11996
|
+
}
|
|
11997
|
+
/**
|
|
11998
|
+
* Create a comparator function for navigable indexes.
|
|
11999
|
+
*/
|
|
12000
|
+
createComparator(type) {
|
|
12001
|
+
switch (type) {
|
|
12002
|
+
case "number":
|
|
12003
|
+
return (a, b) => {
|
|
12004
|
+
const numA = typeof a === "number" ? a : parseFloat(String(a));
|
|
12005
|
+
const numB = typeof b === "number" ? b : parseFloat(String(b));
|
|
12006
|
+
return numA - numB;
|
|
12007
|
+
};
|
|
12008
|
+
case "date":
|
|
12009
|
+
return (a, b) => {
|
|
12010
|
+
const dateA = new Date(a).getTime();
|
|
12011
|
+
const dateB = new Date(b).getTime();
|
|
12012
|
+
return dateA - dateB;
|
|
12013
|
+
};
|
|
12014
|
+
case "string":
|
|
12015
|
+
return (a, b) => {
|
|
12016
|
+
const strA = String(a);
|
|
12017
|
+
const strB = String(b);
|
|
12018
|
+
return strA.localeCompare(strB);
|
|
12019
|
+
};
|
|
12020
|
+
default:
|
|
12021
|
+
return void 0;
|
|
12022
|
+
}
|
|
12023
|
+
}
|
|
12024
|
+
/**
|
|
12025
|
+
* Check if a map should be indexed based on configuration.
|
|
12026
|
+
*
|
|
12027
|
+
* @param mapName - Name of the map
|
|
12028
|
+
* @returns true if map has index configuration
|
|
12029
|
+
*/
|
|
12030
|
+
hasIndexConfig(mapName) {
|
|
12031
|
+
const config = this.mapConfigs.get(mapName);
|
|
12032
|
+
return config !== void 0 && config.indexes.length > 0;
|
|
12033
|
+
}
|
|
12034
|
+
/**
|
|
12035
|
+
* Get index configuration for a map.
|
|
12036
|
+
*
|
|
12037
|
+
* @param mapName - Name of the map
|
|
12038
|
+
* @returns Map index config or undefined
|
|
12039
|
+
*/
|
|
12040
|
+
getMapConfig(mapName) {
|
|
12041
|
+
return this.mapConfigs.get(mapName);
|
|
12042
|
+
}
|
|
12043
|
+
/**
|
|
12044
|
+
* Get all configured map names.
|
|
12045
|
+
*
|
|
12046
|
+
* @returns Array of map names with index configuration
|
|
12047
|
+
*/
|
|
12048
|
+
getConfiguredMaps() {
|
|
12049
|
+
return Array.from(this.mapConfigs.keys());
|
|
12050
|
+
}
|
|
12051
|
+
/**
|
|
12052
|
+
* Get the full server index configuration.
|
|
12053
|
+
*/
|
|
12054
|
+
getConfig() {
|
|
12055
|
+
return this.config;
|
|
12056
|
+
}
|
|
12057
|
+
};
|
|
9575
12058
|
export {
|
|
9576
12059
|
BufferPool,
|
|
9577
12060
|
ClusterCoordinator,
|
|
9578
12061
|
ClusterManager,
|
|
12062
|
+
ConflictResolverHandler,
|
|
12063
|
+
ConflictResolverService,
|
|
9579
12064
|
ConnectionRateLimiter,
|
|
9580
12065
|
DEFAULT_CLUSTER_COORDINATOR_CONFIG,
|
|
12066
|
+
DEFAULT_CONFLICT_RESOLVER_CONFIG,
|
|
12067
|
+
DEFAULT_INDEX_CONFIG,
|
|
12068
|
+
DEFAULT_JOURNAL_SERVICE_CONFIG,
|
|
9581
12069
|
DEFAULT_LAG_TRACKER_CONFIG,
|
|
12070
|
+
DEFAULT_SANDBOX_CONFIG,
|
|
12071
|
+
EntryProcessorHandler,
|
|
12072
|
+
EventJournalService,
|
|
9582
12073
|
FilterTasklet,
|
|
9583
12074
|
ForEachTasklet,
|
|
9584
12075
|
IteratorTasklet,
|
|
9585
12076
|
LagTracker,
|
|
9586
12077
|
LockManager,
|
|
12078
|
+
MapFactory,
|
|
9587
12079
|
MapTasklet,
|
|
12080
|
+
MapWithResolver,
|
|
9588
12081
|
MemoryServerAdapter,
|
|
9589
12082
|
MigrationManager,
|
|
9590
12083
|
ObjectPool,
|
|
9591
12084
|
PartitionService,
|
|
9592
12085
|
PostgresAdapter,
|
|
12086
|
+
ProcessorSandbox,
|
|
9593
12087
|
RateLimitInterceptor,
|
|
9594
12088
|
ReduceTasklet,
|
|
9595
12089
|
ReplicationPipeline,
|
|
@@ -9612,10 +12106,12 @@ export {
|
|
|
9612
12106
|
getNativeStats,
|
|
9613
12107
|
logNativeStatus,
|
|
9614
12108
|
logger,
|
|
12109
|
+
mergeWithDefaults,
|
|
9615
12110
|
setGlobalBufferPool,
|
|
9616
12111
|
setGlobalEventPayloadPool,
|
|
9617
12112
|
setGlobalMessagePool,
|
|
9618
12113
|
setGlobalRecordPool,
|
|
9619
|
-
setGlobalTimestampPool
|
|
12114
|
+
setGlobalTimestampPool,
|
|
12115
|
+
validateIndexConfig
|
|
9620
12116
|
};
|
|
9621
12117
|
//# sourceMappingURL=index.mjs.map
|