@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.js
CHANGED
|
@@ -33,20 +33,31 @@ __export(index_exports, {
|
|
|
33
33
|
BufferPool: () => BufferPool,
|
|
34
34
|
ClusterCoordinator: () => ClusterCoordinator,
|
|
35
35
|
ClusterManager: () => ClusterManager,
|
|
36
|
+
ConflictResolverHandler: () => ConflictResolverHandler,
|
|
37
|
+
ConflictResolverService: () => ConflictResolverService,
|
|
36
38
|
ConnectionRateLimiter: () => ConnectionRateLimiter,
|
|
37
39
|
DEFAULT_CLUSTER_COORDINATOR_CONFIG: () => DEFAULT_CLUSTER_COORDINATOR_CONFIG,
|
|
40
|
+
DEFAULT_CONFLICT_RESOLVER_CONFIG: () => DEFAULT_CONFLICT_RESOLVER_CONFIG,
|
|
41
|
+
DEFAULT_INDEX_CONFIG: () => DEFAULT_INDEX_CONFIG,
|
|
42
|
+
DEFAULT_JOURNAL_SERVICE_CONFIG: () => DEFAULT_JOURNAL_SERVICE_CONFIG,
|
|
38
43
|
DEFAULT_LAG_TRACKER_CONFIG: () => DEFAULT_LAG_TRACKER_CONFIG,
|
|
44
|
+
DEFAULT_SANDBOX_CONFIG: () => DEFAULT_SANDBOX_CONFIG,
|
|
45
|
+
EntryProcessorHandler: () => EntryProcessorHandler,
|
|
46
|
+
EventJournalService: () => EventJournalService,
|
|
39
47
|
FilterTasklet: () => FilterTasklet,
|
|
40
48
|
ForEachTasklet: () => ForEachTasklet,
|
|
41
49
|
IteratorTasklet: () => IteratorTasklet,
|
|
42
50
|
LagTracker: () => LagTracker,
|
|
43
51
|
LockManager: () => LockManager,
|
|
52
|
+
MapFactory: () => MapFactory,
|
|
44
53
|
MapTasklet: () => MapTasklet,
|
|
54
|
+
MapWithResolver: () => MapWithResolver,
|
|
45
55
|
MemoryServerAdapter: () => MemoryServerAdapter,
|
|
46
56
|
MigrationManager: () => MigrationManager,
|
|
47
57
|
ObjectPool: () => ObjectPool,
|
|
48
58
|
PartitionService: () => PartitionService,
|
|
49
59
|
PostgresAdapter: () => PostgresAdapter,
|
|
60
|
+
ProcessorSandbox: () => ProcessorSandbox,
|
|
50
61
|
RateLimitInterceptor: () => RateLimitInterceptor,
|
|
51
62
|
ReduceTasklet: () => ReduceTasklet,
|
|
52
63
|
ReplicationPipeline: () => ReplicationPipeline,
|
|
@@ -69,11 +80,13 @@ __export(index_exports, {
|
|
|
69
80
|
getNativeStats: () => getNativeStats,
|
|
70
81
|
logNativeStatus: () => logNativeStatus,
|
|
71
82
|
logger: () => logger,
|
|
83
|
+
mergeWithDefaults: () => mergeWithDefaults,
|
|
72
84
|
setGlobalBufferPool: () => setGlobalBufferPool,
|
|
73
85
|
setGlobalEventPayloadPool: () => setGlobalEventPayloadPool,
|
|
74
86
|
setGlobalMessagePool: () => setGlobalMessagePool,
|
|
75
87
|
setGlobalRecordPool: () => setGlobalRecordPool,
|
|
76
|
-
setGlobalTimestampPool: () => setGlobalTimestampPool
|
|
88
|
+
setGlobalTimestampPool: () => setGlobalTimestampPool,
|
|
89
|
+
validateIndexConfig: () => validateIndexConfig
|
|
77
90
|
});
|
|
78
91
|
module.exports = __toCommonJS(index_exports);
|
|
79
92
|
|
|
@@ -82,7 +95,7 @@ var import_http = require("http");
|
|
|
82
95
|
var import_https = require("https");
|
|
83
96
|
var import_fs2 = require("fs");
|
|
84
97
|
var import_ws3 = require("ws");
|
|
85
|
-
var
|
|
98
|
+
var import_core15 = require("@topgunbuild/core");
|
|
86
99
|
var jwt = __toESM(require("jsonwebtoken"));
|
|
87
100
|
var crypto = __toESM(require("crypto"));
|
|
88
101
|
|
|
@@ -434,14 +447,70 @@ var QueryRegistry = class {
|
|
|
434
447
|
/**
|
|
435
448
|
* Processes a record change for all relevant subscriptions.
|
|
436
449
|
* Calculates diffs and sends updates.
|
|
450
|
+
*
|
|
451
|
+
* For IndexedLWWMap: Uses StandingQueryRegistry for O(1) affected query detection.
|
|
452
|
+
* For regular maps: Falls back to ReverseQueryIndex.
|
|
437
453
|
*/
|
|
438
454
|
processChange(mapName, map, changeKey, changeRecord, oldRecord) {
|
|
439
455
|
const index = this.indexes.get(mapName);
|
|
440
456
|
if (!index) return;
|
|
441
457
|
const newVal = this.extractValue(changeRecord);
|
|
442
458
|
const oldVal = this.extractValue(oldRecord);
|
|
459
|
+
if (map instanceof import_core2.IndexedLWWMap) {
|
|
460
|
+
this.processChangeWithStandingQuery(mapName, map, changeKey, newVal, oldVal);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
this.processChangeWithReverseIndex(mapName, map, changeKey, newVal, oldVal, index);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Process change using IndexedLWWMap's StandingQueryRegistry.
|
|
467
|
+
* O(1) detection of affected queries.
|
|
468
|
+
*/
|
|
469
|
+
processChangeWithStandingQuery(mapName, map, changeKey, newVal, oldVal) {
|
|
470
|
+
const subs = this.subscriptions.get(mapName);
|
|
471
|
+
if (!subs || subs.size === 0) return;
|
|
472
|
+
const subsByQueryId = /* @__PURE__ */ new Map();
|
|
473
|
+
for (const sub of subs) {
|
|
474
|
+
subsByQueryId.set(sub.id, sub);
|
|
475
|
+
}
|
|
476
|
+
const standingRegistry = map.getStandingQueryRegistry();
|
|
477
|
+
let changes;
|
|
478
|
+
if (oldVal === null || oldVal === void 0) {
|
|
479
|
+
if (newVal !== null && newVal !== void 0) {
|
|
480
|
+
changes = standingRegistry.onRecordAdded(changeKey, newVal);
|
|
481
|
+
} else {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
} else if (newVal === null || newVal === void 0) {
|
|
485
|
+
changes = standingRegistry.onRecordRemoved(changeKey, oldVal);
|
|
486
|
+
} else {
|
|
487
|
+
changes = standingRegistry.onRecordUpdated(changeKey, oldVal, newVal);
|
|
488
|
+
}
|
|
489
|
+
for (const sub of subs) {
|
|
490
|
+
const coreQuery = this.convertToCoreQuery(sub.query);
|
|
491
|
+
if (!coreQuery) {
|
|
492
|
+
this.processSubscriptionFallback(sub, map, changeKey, newVal);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
const queryHash = this.hashCoreQuery(coreQuery);
|
|
496
|
+
const change = changes.get(queryHash);
|
|
497
|
+
if (change === "added") {
|
|
498
|
+
sub.previousResultKeys.add(changeKey);
|
|
499
|
+
this.sendUpdate(sub, changeKey, newVal, "UPDATE");
|
|
500
|
+
} else if (change === "removed") {
|
|
501
|
+
sub.previousResultKeys.delete(changeKey);
|
|
502
|
+
this.sendUpdate(sub, changeKey, null, "REMOVE");
|
|
503
|
+
} else if (change === "updated") {
|
|
504
|
+
this.sendUpdate(sub, changeKey, newVal, "UPDATE");
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Process change using legacy ReverseQueryIndex.
|
|
510
|
+
*/
|
|
511
|
+
processChangeWithReverseIndex(mapName, map, changeKey, newVal, oldVal, index) {
|
|
443
512
|
const changedFields = this.getChangedFields(oldVal, newVal);
|
|
444
|
-
if (changedFields !== "ALL" && changedFields.size === 0 &&
|
|
513
|
+
if (changedFields !== "ALL" && changedFields.size === 0 && oldVal && newVal) {
|
|
445
514
|
return;
|
|
446
515
|
}
|
|
447
516
|
const candidates = index.getCandidates(changedFields, oldVal, newVal);
|
|
@@ -483,6 +552,103 @@ var QueryRegistry = class {
|
|
|
483
552
|
sub.previousResultKeys = newResultKeys;
|
|
484
553
|
}
|
|
485
554
|
}
|
|
555
|
+
/**
|
|
556
|
+
* Fallback processing for subscriptions that can't use StandingQueryRegistry.
|
|
557
|
+
*/
|
|
558
|
+
processSubscriptionFallback(sub, map, changeKey, newVal) {
|
|
559
|
+
const dummyRecord = {
|
|
560
|
+
value: newVal,
|
|
561
|
+
timestamp: { millis: 0, counter: 0, nodeId: "" }
|
|
562
|
+
};
|
|
563
|
+
const isMatch = newVal !== null && matchesQuery(dummyRecord, sub.query);
|
|
564
|
+
const wasInResult = sub.previousResultKeys.has(changeKey);
|
|
565
|
+
if (isMatch && !wasInResult) {
|
|
566
|
+
sub.previousResultKeys.add(changeKey);
|
|
567
|
+
this.sendUpdate(sub, changeKey, newVal, "UPDATE");
|
|
568
|
+
} else if (!isMatch && wasInResult) {
|
|
569
|
+
sub.previousResultKeys.delete(changeKey);
|
|
570
|
+
this.sendUpdate(sub, changeKey, null, "REMOVE");
|
|
571
|
+
} else if (isMatch && wasInResult) {
|
|
572
|
+
this.sendUpdate(sub, changeKey, newVal, "UPDATE");
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Convert server Query format to core Query format.
|
|
577
|
+
*/
|
|
578
|
+
convertToCoreQuery(query) {
|
|
579
|
+
if (query.predicate) {
|
|
580
|
+
return this.predicateToCoreQuery(query.predicate);
|
|
581
|
+
}
|
|
582
|
+
if (query.where) {
|
|
583
|
+
const conditions = [];
|
|
584
|
+
for (const [attribute, condition] of Object.entries(query.where)) {
|
|
585
|
+
if (typeof condition !== "object" || condition === null) {
|
|
586
|
+
conditions.push({ type: "eq", attribute, value: condition });
|
|
587
|
+
} else {
|
|
588
|
+
for (const [op, value] of Object.entries(condition)) {
|
|
589
|
+
const coreOp = this.convertOperator(op);
|
|
590
|
+
if (coreOp) {
|
|
591
|
+
conditions.push({ type: coreOp, attribute, value });
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (conditions.length === 0) return null;
|
|
597
|
+
if (conditions.length === 1) return conditions[0];
|
|
598
|
+
return { type: "and", children: conditions };
|
|
599
|
+
}
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
predicateToCoreQuery(predicate) {
|
|
603
|
+
if (!predicate || !predicate.op) return null;
|
|
604
|
+
switch (predicate.op) {
|
|
605
|
+
case "eq":
|
|
606
|
+
case "neq":
|
|
607
|
+
case "gt":
|
|
608
|
+
case "gte":
|
|
609
|
+
case "lt":
|
|
610
|
+
case "lte":
|
|
611
|
+
return {
|
|
612
|
+
type: predicate.op,
|
|
613
|
+
attribute: predicate.attribute,
|
|
614
|
+
value: predicate.value
|
|
615
|
+
};
|
|
616
|
+
case "and":
|
|
617
|
+
case "or":
|
|
618
|
+
if (predicate.children && Array.isArray(predicate.children)) {
|
|
619
|
+
const children = predicate.children.map((c) => this.predicateToCoreQuery(c)).filter((c) => c !== null);
|
|
620
|
+
if (children.length === 0) return null;
|
|
621
|
+
if (children.length === 1) return children[0];
|
|
622
|
+
return { type: predicate.op, children };
|
|
623
|
+
}
|
|
624
|
+
return null;
|
|
625
|
+
case "not":
|
|
626
|
+
if (predicate.children && predicate.children[0]) {
|
|
627
|
+
const child = this.predicateToCoreQuery(predicate.children[0]);
|
|
628
|
+
if (child) {
|
|
629
|
+
return { type: "not", child };
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return null;
|
|
633
|
+
default:
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
convertOperator(op) {
|
|
638
|
+
const mapping = {
|
|
639
|
+
"$eq": "eq",
|
|
640
|
+
"$ne": "neq",
|
|
641
|
+
"$neq": "neq",
|
|
642
|
+
"$gt": "gt",
|
|
643
|
+
"$gte": "gte",
|
|
644
|
+
"$lt": "lt",
|
|
645
|
+
"$lte": "lte"
|
|
646
|
+
};
|
|
647
|
+
return mapping[op] || null;
|
|
648
|
+
}
|
|
649
|
+
hashCoreQuery(query) {
|
|
650
|
+
return JSON.stringify(query);
|
|
651
|
+
}
|
|
486
652
|
extractValue(record) {
|
|
487
653
|
if (!record) return null;
|
|
488
654
|
if (Array.isArray(record)) {
|
|
@@ -6468,118 +6634,1487 @@ var ReplicationPipeline = class extends import_events8.EventEmitter {
|
|
|
6468
6634
|
}
|
|
6469
6635
|
};
|
|
6470
6636
|
|
|
6471
|
-
// src/
|
|
6472
|
-
var
|
|
6473
|
-
var
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
this.
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
const
|
|
6498
|
-
this.
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
name: `${config.nodeId}-event-executor`,
|
|
6507
|
-
onReject: (task) => {
|
|
6508
|
-
logger.warn({ nodeId: config.nodeId, key: task.key }, "Event task rejected due to queue capacity");
|
|
6509
|
-
this.metricsService.incEventQueueRejected();
|
|
6637
|
+
// src/handlers/CounterHandler.ts
|
|
6638
|
+
var import_core10 = require("@topgunbuild/core");
|
|
6639
|
+
var CounterHandler = class {
|
|
6640
|
+
// counterName -> Set<clientId>
|
|
6641
|
+
constructor(nodeId = "server") {
|
|
6642
|
+
this.nodeId = nodeId;
|
|
6643
|
+
this.counters = /* @__PURE__ */ new Map();
|
|
6644
|
+
this.subscriptions = /* @__PURE__ */ new Map();
|
|
6645
|
+
}
|
|
6646
|
+
/**
|
|
6647
|
+
* Get or create a counter by name.
|
|
6648
|
+
*/
|
|
6649
|
+
getOrCreateCounter(name) {
|
|
6650
|
+
let counter = this.counters.get(name);
|
|
6651
|
+
if (!counter) {
|
|
6652
|
+
counter = new import_core10.PNCounterImpl({ nodeId: this.nodeId });
|
|
6653
|
+
this.counters.set(name, counter);
|
|
6654
|
+
logger.debug({ name }, "Created new counter");
|
|
6655
|
+
}
|
|
6656
|
+
return counter;
|
|
6657
|
+
}
|
|
6658
|
+
/**
|
|
6659
|
+
* Handle COUNTER_REQUEST - client wants initial state.
|
|
6660
|
+
* @returns Response message to send back to client
|
|
6661
|
+
*/
|
|
6662
|
+
handleCounterRequest(clientId, name) {
|
|
6663
|
+
const counter = this.getOrCreateCounter(name);
|
|
6664
|
+
this.subscribe(clientId, name);
|
|
6665
|
+
const state = counter.getState();
|
|
6666
|
+
logger.debug({ clientId, name, value: counter.get() }, "Counter request handled");
|
|
6667
|
+
return {
|
|
6668
|
+
type: "COUNTER_RESPONSE",
|
|
6669
|
+
payload: {
|
|
6670
|
+
name,
|
|
6671
|
+
state: this.stateToObject(state)
|
|
6510
6672
|
}
|
|
6511
|
-
});
|
|
6512
|
-
this.backpressure = new BackpressureRegulator({
|
|
6513
|
-
syncFrequency: config.backpressureSyncFrequency ?? 100,
|
|
6514
|
-
maxPendingOps: config.backpressureMaxPending ?? 1e3,
|
|
6515
|
-
backoffTimeoutMs: config.backpressureBackoffMs ?? 5e3,
|
|
6516
|
-
enabled: config.backpressureEnabled ?? true
|
|
6517
|
-
});
|
|
6518
|
-
this.writeCoalescingEnabled = config.writeCoalescingEnabled ?? true;
|
|
6519
|
-
const preset = coalescingPresets[config.writeCoalescingPreset ?? "highThroughput"];
|
|
6520
|
-
this.writeCoalescingOptions = {
|
|
6521
|
-
maxBatchSize: config.writeCoalescingMaxBatch ?? preset.maxBatchSize,
|
|
6522
|
-
maxDelayMs: config.writeCoalescingMaxDelayMs ?? preset.maxDelayMs,
|
|
6523
|
-
maxBatchBytes: config.writeCoalescingMaxBytes ?? preset.maxBatchBytes
|
|
6524
6673
|
};
|
|
6525
|
-
|
|
6526
|
-
|
|
6527
|
-
|
|
6528
|
-
|
|
6529
|
-
|
|
6530
|
-
|
|
6531
|
-
|
|
6532
|
-
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
6536
|
-
|
|
6537
|
-
|
|
6538
|
-
|
|
6539
|
-
|
|
6540
|
-
|
|
6541
|
-
|
|
6542
|
-
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
|
|
6546
|
-
|
|
6547
|
-
|
|
6548
|
-
|
|
6549
|
-
|
|
6550
|
-
|
|
6551
|
-
|
|
6552
|
-
|
|
6553
|
-
|
|
6554
|
-
|
|
6555
|
-
|
|
6556
|
-
|
|
6674
|
+
}
|
|
6675
|
+
/**
|
|
6676
|
+
* Handle COUNTER_SYNC - client sends their state to merge.
|
|
6677
|
+
* @returns Merged state and list of clients to broadcast to
|
|
6678
|
+
*/
|
|
6679
|
+
handleCounterSync(clientId, name, stateObj) {
|
|
6680
|
+
const counter = this.getOrCreateCounter(name);
|
|
6681
|
+
const incomingState = this.objectToState(stateObj);
|
|
6682
|
+
counter.merge(incomingState);
|
|
6683
|
+
const mergedState = counter.getState();
|
|
6684
|
+
const mergedStateObj = this.stateToObject(mergedState);
|
|
6685
|
+
logger.debug(
|
|
6686
|
+
{ clientId, name, value: counter.get() },
|
|
6687
|
+
"Counter sync handled"
|
|
6688
|
+
);
|
|
6689
|
+
this.subscribe(clientId, name);
|
|
6690
|
+
const subscribers = this.subscriptions.get(name) || /* @__PURE__ */ new Set();
|
|
6691
|
+
const broadcastTo = Array.from(subscribers).filter((id) => id !== clientId);
|
|
6692
|
+
return {
|
|
6693
|
+
// Response to the sending client
|
|
6694
|
+
response: {
|
|
6695
|
+
type: "COUNTER_UPDATE",
|
|
6696
|
+
payload: {
|
|
6697
|
+
name,
|
|
6698
|
+
state: mergedStateObj
|
|
6699
|
+
}
|
|
6700
|
+
},
|
|
6701
|
+
// Broadcast to other clients
|
|
6702
|
+
broadcastTo,
|
|
6703
|
+
broadcastMessage: {
|
|
6704
|
+
type: "COUNTER_UPDATE",
|
|
6705
|
+
payload: {
|
|
6706
|
+
name,
|
|
6707
|
+
state: mergedStateObj
|
|
6708
|
+
}
|
|
6709
|
+
}
|
|
6710
|
+
};
|
|
6711
|
+
}
|
|
6712
|
+
/**
|
|
6713
|
+
* Subscribe a client to counter updates.
|
|
6714
|
+
*/
|
|
6715
|
+
subscribe(clientId, counterName) {
|
|
6716
|
+
if (!this.subscriptions.has(counterName)) {
|
|
6717
|
+
this.subscriptions.set(counterName, /* @__PURE__ */ new Set());
|
|
6557
6718
|
}
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
|
|
6563
|
-
|
|
6564
|
-
|
|
6719
|
+
this.subscriptions.get(counterName).add(clientId);
|
|
6720
|
+
logger.debug({ clientId, counterName }, "Client subscribed to counter");
|
|
6721
|
+
}
|
|
6722
|
+
/**
|
|
6723
|
+
* Unsubscribe a client from counter updates.
|
|
6724
|
+
*/
|
|
6725
|
+
unsubscribe(clientId, counterName) {
|
|
6726
|
+
const subs = this.subscriptions.get(counterName);
|
|
6727
|
+
if (subs) {
|
|
6728
|
+
subs.delete(clientId);
|
|
6729
|
+
if (subs.size === 0) {
|
|
6730
|
+
this.subscriptions.delete(counterName);
|
|
6731
|
+
}
|
|
6732
|
+
}
|
|
6733
|
+
}
|
|
6734
|
+
/**
|
|
6735
|
+
* Unsubscribe a client from all counters (e.g., on disconnect).
|
|
6736
|
+
*/
|
|
6737
|
+
unsubscribeAll(clientId) {
|
|
6738
|
+
for (const [counterName, subs] of this.subscriptions) {
|
|
6739
|
+
subs.delete(clientId);
|
|
6740
|
+
if (subs.size === 0) {
|
|
6741
|
+
this.subscriptions.delete(counterName);
|
|
6742
|
+
}
|
|
6743
|
+
}
|
|
6744
|
+
logger.debug({ clientId }, "Client unsubscribed from all counters");
|
|
6745
|
+
}
|
|
6746
|
+
/**
|
|
6747
|
+
* Get current counter value (for monitoring/debugging).
|
|
6748
|
+
*/
|
|
6749
|
+
getCounterValue(name) {
|
|
6750
|
+
const counter = this.counters.get(name);
|
|
6751
|
+
return counter ? counter.get() : 0;
|
|
6752
|
+
}
|
|
6753
|
+
/**
|
|
6754
|
+
* Get all counter names.
|
|
6755
|
+
*/
|
|
6756
|
+
getCounterNames() {
|
|
6757
|
+
return Array.from(this.counters.keys());
|
|
6758
|
+
}
|
|
6759
|
+
/**
|
|
6760
|
+
* Get number of subscribers for a counter.
|
|
6761
|
+
*/
|
|
6762
|
+
getSubscriberCount(name) {
|
|
6763
|
+
return this.subscriptions.get(name)?.size || 0;
|
|
6764
|
+
}
|
|
6765
|
+
/**
|
|
6766
|
+
* Convert Map-based state to plain object for serialization.
|
|
6767
|
+
*/
|
|
6768
|
+
stateToObject(state) {
|
|
6769
|
+
return {
|
|
6770
|
+
p: Object.fromEntries(state.positive),
|
|
6771
|
+
n: Object.fromEntries(state.negative)
|
|
6772
|
+
};
|
|
6773
|
+
}
|
|
6774
|
+
/**
|
|
6775
|
+
* Convert plain object to Map-based state.
|
|
6776
|
+
*/
|
|
6777
|
+
objectToState(obj) {
|
|
6778
|
+
return {
|
|
6779
|
+
positive: new Map(Object.entries(obj.p || {})),
|
|
6780
|
+
negative: new Map(Object.entries(obj.n || {}))
|
|
6781
|
+
};
|
|
6782
|
+
}
|
|
6783
|
+
};
|
|
6784
|
+
|
|
6785
|
+
// src/handlers/EntryProcessorHandler.ts
|
|
6786
|
+
var import_core12 = require("@topgunbuild/core");
|
|
6787
|
+
|
|
6788
|
+
// src/ProcessorSandbox.ts
|
|
6789
|
+
var import_core11 = require("@topgunbuild/core");
|
|
6790
|
+
var ivm = null;
|
|
6791
|
+
try {
|
|
6792
|
+
ivm = require("isolated-vm");
|
|
6793
|
+
} catch {
|
|
6794
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
6795
|
+
if (isProduction) {
|
|
6796
|
+
logger.error(
|
|
6797
|
+
"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"
|
|
6798
|
+
);
|
|
6799
|
+
} else {
|
|
6800
|
+
logger.warn("isolated-vm not available, falling back to less secure VM");
|
|
6801
|
+
}
|
|
6802
|
+
}
|
|
6803
|
+
var DEFAULT_SANDBOX_CONFIG = {
|
|
6804
|
+
memoryLimitMb: 8,
|
|
6805
|
+
timeoutMs: 100,
|
|
6806
|
+
maxCachedIsolates: 100,
|
|
6807
|
+
strictValidation: true
|
|
6808
|
+
};
|
|
6809
|
+
var ProcessorSandbox = class {
|
|
6810
|
+
constructor(config = {}) {
|
|
6811
|
+
this.isolateCache = /* @__PURE__ */ new Map();
|
|
6812
|
+
this.scriptCache = /* @__PURE__ */ new Map();
|
|
6813
|
+
this.fallbackScriptCache = /* @__PURE__ */ new Map();
|
|
6814
|
+
this.disposed = false;
|
|
6815
|
+
this.config = { ...DEFAULT_SANDBOX_CONFIG, ...config };
|
|
6816
|
+
}
|
|
6817
|
+
/**
|
|
6818
|
+
* Execute an entry processor in the sandbox.
|
|
6819
|
+
*
|
|
6820
|
+
* @param processor The processor definition (name, code, args)
|
|
6821
|
+
* @param value The current value for the key (or undefined)
|
|
6822
|
+
* @param key The key being processed
|
|
6823
|
+
* @returns Result containing success status, result, and new value
|
|
6824
|
+
*/
|
|
6825
|
+
async execute(processor, value, key) {
|
|
6826
|
+
if (this.disposed) {
|
|
6827
|
+
return {
|
|
6828
|
+
success: false,
|
|
6829
|
+
error: "Sandbox has been disposed"
|
|
6830
|
+
};
|
|
6831
|
+
}
|
|
6832
|
+
if (this.config.strictValidation) {
|
|
6833
|
+
const validation = (0, import_core11.validateProcessorCode)(processor.code);
|
|
6834
|
+
if (!validation.valid) {
|
|
6835
|
+
return {
|
|
6836
|
+
success: false,
|
|
6837
|
+
error: validation.error
|
|
6838
|
+
};
|
|
6839
|
+
}
|
|
6840
|
+
}
|
|
6841
|
+
if (ivm) {
|
|
6842
|
+
return this.executeInIsolate(processor, value, key);
|
|
6565
6843
|
} else {
|
|
6566
|
-
this.
|
|
6567
|
-
|
|
6568
|
-
|
|
6844
|
+
return this.executeInFallback(processor, value, key);
|
|
6845
|
+
}
|
|
6846
|
+
}
|
|
6847
|
+
/**
|
|
6848
|
+
* Execute processor in isolated-vm (secure production mode).
|
|
6849
|
+
*/
|
|
6850
|
+
async executeInIsolate(processor, value, key) {
|
|
6851
|
+
if (!ivm) {
|
|
6852
|
+
return { success: false, error: "isolated-vm not available" };
|
|
6853
|
+
}
|
|
6854
|
+
const isolate = this.getOrCreateIsolate(processor.name);
|
|
6855
|
+
try {
|
|
6856
|
+
const context = await isolate.createContext();
|
|
6857
|
+
const jail = context.global;
|
|
6858
|
+
await jail.set("global", jail.derefInto());
|
|
6859
|
+
await context.eval(`
|
|
6860
|
+
var value = ${JSON.stringify(value)};
|
|
6861
|
+
var key = ${JSON.stringify(key)};
|
|
6862
|
+
var args = ${JSON.stringify(processor.args)};
|
|
6863
|
+
`);
|
|
6864
|
+
const wrappedCode = `
|
|
6865
|
+
(function() {
|
|
6866
|
+
${processor.code}
|
|
6867
|
+
})()
|
|
6868
|
+
`;
|
|
6869
|
+
const script = await this.getOrCompileScript(
|
|
6870
|
+
processor.name,
|
|
6871
|
+
wrappedCode,
|
|
6872
|
+
isolate
|
|
6873
|
+
);
|
|
6874
|
+
const result = await script.run(context, {
|
|
6875
|
+
timeout: this.config.timeoutMs
|
|
6569
6876
|
});
|
|
6570
|
-
|
|
6571
|
-
|
|
6877
|
+
const parsed = result;
|
|
6878
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
6879
|
+
return {
|
|
6880
|
+
success: false,
|
|
6881
|
+
error: "Processor must return { value, result? } object"
|
|
6882
|
+
};
|
|
6572
6883
|
}
|
|
6884
|
+
return {
|
|
6885
|
+
success: true,
|
|
6886
|
+
result: parsed.result,
|
|
6887
|
+
newValue: parsed.value
|
|
6888
|
+
};
|
|
6889
|
+
} catch (error) {
|
|
6890
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6891
|
+
if (message.includes("Script execution timed out")) {
|
|
6892
|
+
return {
|
|
6893
|
+
success: false,
|
|
6894
|
+
error: "Processor execution timed out"
|
|
6895
|
+
};
|
|
6896
|
+
}
|
|
6897
|
+
return {
|
|
6898
|
+
success: false,
|
|
6899
|
+
error: message
|
|
6900
|
+
};
|
|
6573
6901
|
}
|
|
6574
|
-
|
|
6575
|
-
|
|
6576
|
-
|
|
6577
|
-
|
|
6578
|
-
|
|
6579
|
-
|
|
6580
|
-
|
|
6581
|
-
|
|
6582
|
-
|
|
6902
|
+
}
|
|
6903
|
+
/**
|
|
6904
|
+
* Execute processor in fallback VM (less secure, for development).
|
|
6905
|
+
*/
|
|
6906
|
+
async executeInFallback(processor, value, key) {
|
|
6907
|
+
try {
|
|
6908
|
+
const isResolver = processor.name.startsWith("resolver:");
|
|
6909
|
+
let fn = isResolver ? void 0 : this.fallbackScriptCache.get(processor.name);
|
|
6910
|
+
if (!fn) {
|
|
6911
|
+
const wrappedCode = `
|
|
6912
|
+
return (function(value, key, args) {
|
|
6913
|
+
${processor.code}
|
|
6914
|
+
})
|
|
6915
|
+
`;
|
|
6916
|
+
fn = new Function(wrappedCode)();
|
|
6917
|
+
if (!isResolver) {
|
|
6918
|
+
this.fallbackScriptCache.set(processor.name, fn);
|
|
6919
|
+
}
|
|
6920
|
+
}
|
|
6921
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
6922
|
+
setTimeout(() => reject(new Error("Processor execution timed out")), this.config.timeoutMs);
|
|
6923
|
+
});
|
|
6924
|
+
const executionPromise = Promise.resolve().then(() => fn(value, key, processor.args));
|
|
6925
|
+
const result = await Promise.race([executionPromise, timeoutPromise]);
|
|
6926
|
+
if (typeof result !== "object" || result === null) {
|
|
6927
|
+
return {
|
|
6928
|
+
success: false,
|
|
6929
|
+
error: "Processor must return { value, result? } object"
|
|
6930
|
+
};
|
|
6931
|
+
}
|
|
6932
|
+
return {
|
|
6933
|
+
success: true,
|
|
6934
|
+
result: result.result,
|
|
6935
|
+
newValue: result.value
|
|
6936
|
+
};
|
|
6937
|
+
} catch (error) {
|
|
6938
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6939
|
+
return {
|
|
6940
|
+
success: false,
|
|
6941
|
+
error: message
|
|
6942
|
+
};
|
|
6943
|
+
}
|
|
6944
|
+
}
|
|
6945
|
+
/**
|
|
6946
|
+
* Get or create an isolate for a processor.
|
|
6947
|
+
*/
|
|
6948
|
+
getOrCreateIsolate(name) {
|
|
6949
|
+
if (!ivm) {
|
|
6950
|
+
throw new Error("isolated-vm not available");
|
|
6951
|
+
}
|
|
6952
|
+
let isolate = this.isolateCache.get(name);
|
|
6953
|
+
if (!isolate || isolate.isDisposed) {
|
|
6954
|
+
if (this.isolateCache.size >= this.config.maxCachedIsolates) {
|
|
6955
|
+
const oldest = this.isolateCache.keys().next().value;
|
|
6956
|
+
if (oldest) {
|
|
6957
|
+
const oldIsolate = this.isolateCache.get(oldest);
|
|
6958
|
+
if (oldIsolate && !oldIsolate.isDisposed) {
|
|
6959
|
+
oldIsolate.dispose();
|
|
6960
|
+
}
|
|
6961
|
+
this.isolateCache.delete(oldest);
|
|
6962
|
+
this.scriptCache.delete(oldest);
|
|
6963
|
+
}
|
|
6964
|
+
}
|
|
6965
|
+
isolate = new ivm.Isolate({
|
|
6966
|
+
memoryLimit: this.config.memoryLimitMb
|
|
6967
|
+
});
|
|
6968
|
+
this.isolateCache.set(name, isolate);
|
|
6969
|
+
}
|
|
6970
|
+
return isolate;
|
|
6971
|
+
}
|
|
6972
|
+
/**
|
|
6973
|
+
* Get or compile a script for a processor.
|
|
6974
|
+
*/
|
|
6975
|
+
async getOrCompileScript(name, code, isolate) {
|
|
6976
|
+
let script = this.scriptCache.get(name);
|
|
6977
|
+
if (!script) {
|
|
6978
|
+
script = await isolate.compileScript(code);
|
|
6979
|
+
this.scriptCache.set(name, script);
|
|
6980
|
+
}
|
|
6981
|
+
return script;
|
|
6982
|
+
}
|
|
6983
|
+
/**
|
|
6984
|
+
* Clear script cache for a specific processor (e.g., when code changes).
|
|
6985
|
+
*/
|
|
6986
|
+
clearCache(processorName) {
|
|
6987
|
+
if (processorName) {
|
|
6988
|
+
const isolate = this.isolateCache.get(processorName);
|
|
6989
|
+
if (isolate && !isolate.isDisposed) {
|
|
6990
|
+
isolate.dispose();
|
|
6991
|
+
}
|
|
6992
|
+
this.isolateCache.delete(processorName);
|
|
6993
|
+
this.scriptCache.delete(processorName);
|
|
6994
|
+
this.fallbackScriptCache.delete(processorName);
|
|
6995
|
+
} else {
|
|
6996
|
+
for (const isolate of this.isolateCache.values()) {
|
|
6997
|
+
if (!isolate.isDisposed) {
|
|
6998
|
+
isolate.dispose();
|
|
6999
|
+
}
|
|
7000
|
+
}
|
|
7001
|
+
this.isolateCache.clear();
|
|
7002
|
+
this.scriptCache.clear();
|
|
7003
|
+
this.fallbackScriptCache.clear();
|
|
7004
|
+
}
|
|
7005
|
+
}
|
|
7006
|
+
/**
|
|
7007
|
+
* Check if using secure isolated-vm mode.
|
|
7008
|
+
*/
|
|
7009
|
+
isSecureMode() {
|
|
7010
|
+
return ivm !== null;
|
|
7011
|
+
}
|
|
7012
|
+
/**
|
|
7013
|
+
* Get current cache sizes.
|
|
7014
|
+
*/
|
|
7015
|
+
getCacheStats() {
|
|
7016
|
+
return {
|
|
7017
|
+
isolates: this.isolateCache.size,
|
|
7018
|
+
scripts: this.scriptCache.size,
|
|
7019
|
+
fallbackScripts: this.fallbackScriptCache.size
|
|
7020
|
+
};
|
|
7021
|
+
}
|
|
7022
|
+
/**
|
|
7023
|
+
* Dispose of all isolates and clear caches.
|
|
7024
|
+
*/
|
|
7025
|
+
dispose() {
|
|
7026
|
+
if (this.disposed) return;
|
|
7027
|
+
this.disposed = true;
|
|
7028
|
+
this.clearCache();
|
|
7029
|
+
logger.debug("ProcessorSandbox disposed");
|
|
7030
|
+
}
|
|
7031
|
+
};
|
|
7032
|
+
|
|
7033
|
+
// src/handlers/EntryProcessorHandler.ts
|
|
7034
|
+
var EntryProcessorHandler = class {
|
|
7035
|
+
constructor(config) {
|
|
7036
|
+
this.hlc = config.hlc;
|
|
7037
|
+
this.sandbox = new ProcessorSandbox(config.sandboxConfig);
|
|
7038
|
+
}
|
|
7039
|
+
/**
|
|
7040
|
+
* Execute a processor on a single key atomically.
|
|
7041
|
+
*
|
|
7042
|
+
* @param map The LWWMap to operate on
|
|
7043
|
+
* @param key The key to process
|
|
7044
|
+
* @param processorDef The processor definition (will be validated)
|
|
7045
|
+
* @returns Result with success status, processor result, and new value
|
|
7046
|
+
*/
|
|
7047
|
+
async executeOnKey(map, key, processorDef) {
|
|
7048
|
+
const parseResult = import_core12.EntryProcessorDefSchema.safeParse(processorDef);
|
|
7049
|
+
if (!parseResult.success) {
|
|
7050
|
+
logger.warn(
|
|
7051
|
+
{ key, error: parseResult.error.message },
|
|
7052
|
+
"Invalid processor definition"
|
|
7053
|
+
);
|
|
7054
|
+
return {
|
|
7055
|
+
result: {
|
|
7056
|
+
success: false,
|
|
7057
|
+
error: `Invalid processor: ${parseResult.error.message}`
|
|
7058
|
+
}
|
|
7059
|
+
};
|
|
7060
|
+
}
|
|
7061
|
+
const processor = parseResult.data;
|
|
7062
|
+
const currentValue = map.get(key);
|
|
7063
|
+
logger.debug(
|
|
7064
|
+
{ key, processor: processor.name, hasValue: currentValue !== void 0 },
|
|
7065
|
+
"Executing entry processor"
|
|
7066
|
+
);
|
|
7067
|
+
const sandboxResult = await this.sandbox.execute(
|
|
7068
|
+
processor,
|
|
7069
|
+
currentValue,
|
|
7070
|
+
key
|
|
7071
|
+
);
|
|
7072
|
+
if (!sandboxResult.success) {
|
|
7073
|
+
logger.warn(
|
|
7074
|
+
{ key, processor: processor.name, error: sandboxResult.error },
|
|
7075
|
+
"Processor execution failed"
|
|
7076
|
+
);
|
|
7077
|
+
return { result: sandboxResult };
|
|
7078
|
+
}
|
|
7079
|
+
let timestamp;
|
|
7080
|
+
if (sandboxResult.newValue !== void 0) {
|
|
7081
|
+
const record = map.set(key, sandboxResult.newValue);
|
|
7082
|
+
timestamp = record.timestamp;
|
|
7083
|
+
logger.debug(
|
|
7084
|
+
{ key, processor: processor.name, timestamp },
|
|
7085
|
+
"Processor updated value"
|
|
7086
|
+
);
|
|
7087
|
+
} else if (currentValue !== void 0) {
|
|
7088
|
+
const tombstone = map.remove(key);
|
|
7089
|
+
timestamp = tombstone.timestamp;
|
|
7090
|
+
logger.debug(
|
|
7091
|
+
{ key, processor: processor.name, timestamp },
|
|
7092
|
+
"Processor deleted value"
|
|
7093
|
+
);
|
|
7094
|
+
}
|
|
7095
|
+
return {
|
|
7096
|
+
result: sandboxResult,
|
|
7097
|
+
timestamp
|
|
7098
|
+
};
|
|
7099
|
+
}
|
|
7100
|
+
/**
|
|
7101
|
+
* Execute a processor on multiple keys.
|
|
7102
|
+
*
|
|
7103
|
+
* Each key is processed sequentially to ensure atomicity per-key.
|
|
7104
|
+
* For parallel execution across keys, use multiple calls.
|
|
7105
|
+
*
|
|
7106
|
+
* @param map The LWWMap to operate on
|
|
7107
|
+
* @param keys The keys to process
|
|
7108
|
+
* @param processorDef The processor definition
|
|
7109
|
+
* @returns Map of key -> result
|
|
7110
|
+
*/
|
|
7111
|
+
async executeOnKeys(map, keys, processorDef) {
|
|
7112
|
+
const results = /* @__PURE__ */ new Map();
|
|
7113
|
+
const timestamps = /* @__PURE__ */ new Map();
|
|
7114
|
+
const parseResult = import_core12.EntryProcessorDefSchema.safeParse(processorDef);
|
|
7115
|
+
if (!parseResult.success) {
|
|
7116
|
+
const errorResult = {
|
|
7117
|
+
success: false,
|
|
7118
|
+
error: `Invalid processor: ${parseResult.error.message}`
|
|
7119
|
+
};
|
|
7120
|
+
for (const key of keys) {
|
|
7121
|
+
results.set(key, errorResult);
|
|
7122
|
+
}
|
|
7123
|
+
return { results, timestamps };
|
|
7124
|
+
}
|
|
7125
|
+
for (const key of keys) {
|
|
7126
|
+
const { result, timestamp } = await this.executeOnKey(
|
|
7127
|
+
map,
|
|
7128
|
+
key,
|
|
7129
|
+
processorDef
|
|
7130
|
+
);
|
|
7131
|
+
results.set(key, result);
|
|
7132
|
+
if (timestamp) {
|
|
7133
|
+
timestamps.set(key, timestamp);
|
|
7134
|
+
}
|
|
7135
|
+
}
|
|
7136
|
+
return { results, timestamps };
|
|
7137
|
+
}
|
|
7138
|
+
/**
|
|
7139
|
+
* Execute a processor on all entries matching a predicate.
|
|
7140
|
+
*
|
|
7141
|
+
* WARNING: This can be expensive for large maps.
|
|
7142
|
+
*
|
|
7143
|
+
* @param map The LWWMap to operate on
|
|
7144
|
+
* @param processorDef The processor definition
|
|
7145
|
+
* @param predicateCode Optional predicate code to filter entries
|
|
7146
|
+
* @returns Map of key -> result for processed entries
|
|
7147
|
+
*/
|
|
7148
|
+
async executeOnEntries(map, processorDef, predicateCode) {
|
|
7149
|
+
const results = /* @__PURE__ */ new Map();
|
|
7150
|
+
const timestamps = /* @__PURE__ */ new Map();
|
|
7151
|
+
const parseResult = import_core12.EntryProcessorDefSchema.safeParse(processorDef);
|
|
7152
|
+
if (!parseResult.success) {
|
|
7153
|
+
return { results, timestamps };
|
|
7154
|
+
}
|
|
7155
|
+
const entries = map.entries();
|
|
7156
|
+
for (const [key, value] of entries) {
|
|
7157
|
+
if (predicateCode) {
|
|
7158
|
+
const predicateResult = await this.sandbox.execute(
|
|
7159
|
+
{
|
|
7160
|
+
name: "_predicate",
|
|
7161
|
+
code: `return { value, result: (function() { ${predicateCode} })() };`
|
|
7162
|
+
},
|
|
7163
|
+
value,
|
|
7164
|
+
key
|
|
7165
|
+
);
|
|
7166
|
+
if (!predicateResult.success || !predicateResult.result) {
|
|
7167
|
+
continue;
|
|
7168
|
+
}
|
|
7169
|
+
}
|
|
7170
|
+
const { result, timestamp } = await this.executeOnKey(
|
|
7171
|
+
map,
|
|
7172
|
+
key,
|
|
7173
|
+
processorDef
|
|
7174
|
+
);
|
|
7175
|
+
results.set(key, result);
|
|
7176
|
+
if (timestamp) {
|
|
7177
|
+
timestamps.set(key, timestamp);
|
|
7178
|
+
}
|
|
7179
|
+
}
|
|
7180
|
+
return { results, timestamps };
|
|
7181
|
+
}
|
|
7182
|
+
/**
|
|
7183
|
+
* Check if sandbox is in secure mode (using isolated-vm).
|
|
7184
|
+
*/
|
|
7185
|
+
isSecureMode() {
|
|
7186
|
+
return this.sandbox.isSecureMode();
|
|
7187
|
+
}
|
|
7188
|
+
/**
|
|
7189
|
+
* Get sandbox cache statistics.
|
|
7190
|
+
*/
|
|
7191
|
+
getCacheStats() {
|
|
7192
|
+
return this.sandbox.getCacheStats();
|
|
7193
|
+
}
|
|
7194
|
+
/**
|
|
7195
|
+
* Clear sandbox cache.
|
|
7196
|
+
*/
|
|
7197
|
+
clearCache(processorName) {
|
|
7198
|
+
this.sandbox.clearCache(processorName);
|
|
7199
|
+
}
|
|
7200
|
+
/**
|
|
7201
|
+
* Dispose of the handler and its sandbox.
|
|
7202
|
+
*/
|
|
7203
|
+
dispose() {
|
|
7204
|
+
this.sandbox.dispose();
|
|
7205
|
+
logger.debug("EntryProcessorHandler disposed");
|
|
7206
|
+
}
|
|
7207
|
+
};
|
|
7208
|
+
|
|
7209
|
+
// src/ConflictResolverService.ts
|
|
7210
|
+
var import_core13 = require("@topgunbuild/core");
|
|
7211
|
+
var DEFAULT_CONFLICT_RESOLVER_CONFIG = {
|
|
7212
|
+
maxResolversPerMap: 100,
|
|
7213
|
+
enableSandboxedResolvers: true,
|
|
7214
|
+
resolverTimeoutMs: 100
|
|
7215
|
+
};
|
|
7216
|
+
var ConflictResolverService = class {
|
|
7217
|
+
constructor(sandbox, config = {}) {
|
|
7218
|
+
this.resolvers = /* @__PURE__ */ new Map();
|
|
7219
|
+
this.disposed = false;
|
|
7220
|
+
this.sandbox = sandbox;
|
|
7221
|
+
this.config = { ...DEFAULT_CONFLICT_RESOLVER_CONFIG, ...config };
|
|
7222
|
+
}
|
|
7223
|
+
/**
|
|
7224
|
+
* Set callback for merge rejections.
|
|
7225
|
+
*/
|
|
7226
|
+
onRejection(callback) {
|
|
7227
|
+
this.onRejectionCallback = callback;
|
|
7228
|
+
}
|
|
7229
|
+
/**
|
|
7230
|
+
* Register a resolver for a map.
|
|
7231
|
+
*
|
|
7232
|
+
* @param mapName The map this resolver applies to
|
|
7233
|
+
* @param resolver The resolver definition
|
|
7234
|
+
* @param registeredBy Optional client ID that registered this resolver
|
|
7235
|
+
*/
|
|
7236
|
+
register(mapName, resolver, registeredBy) {
|
|
7237
|
+
if (this.disposed) {
|
|
7238
|
+
throw new Error("ConflictResolverService has been disposed");
|
|
7239
|
+
}
|
|
7240
|
+
if (resolver.code) {
|
|
7241
|
+
const parsed = import_core13.ConflictResolverDefSchema.safeParse({
|
|
7242
|
+
name: resolver.name,
|
|
7243
|
+
code: resolver.code,
|
|
7244
|
+
priority: resolver.priority,
|
|
7245
|
+
keyPattern: resolver.keyPattern
|
|
7246
|
+
});
|
|
7247
|
+
if (!parsed.success) {
|
|
7248
|
+
throw new Error(`Invalid resolver definition: ${parsed.error.message}`);
|
|
7249
|
+
}
|
|
7250
|
+
const validation = (0, import_core13.validateResolverCode)(resolver.code);
|
|
7251
|
+
if (!validation.valid) {
|
|
7252
|
+
throw new Error(`Invalid resolver code: ${validation.error}`);
|
|
7253
|
+
}
|
|
7254
|
+
}
|
|
7255
|
+
const entries = this.resolvers.get(mapName) ?? [];
|
|
7256
|
+
if (entries.length >= this.config.maxResolversPerMap) {
|
|
7257
|
+
throw new Error(
|
|
7258
|
+
`Maximum resolvers per map (${this.config.maxResolversPerMap}) exceeded`
|
|
7259
|
+
);
|
|
7260
|
+
}
|
|
7261
|
+
const filtered = entries.filter((e) => e.resolver.name !== resolver.name);
|
|
7262
|
+
const entry = {
|
|
7263
|
+
resolver,
|
|
7264
|
+
registeredBy
|
|
7265
|
+
};
|
|
7266
|
+
if (resolver.code && !resolver.fn && this.config.enableSandboxedResolvers) {
|
|
7267
|
+
entry.compiledFn = this.compileSandboxed(resolver.name, resolver.code);
|
|
7268
|
+
}
|
|
7269
|
+
filtered.push(entry);
|
|
7270
|
+
filtered.sort(
|
|
7271
|
+
(a, b) => (b.resolver.priority ?? 50) - (a.resolver.priority ?? 50)
|
|
7272
|
+
);
|
|
7273
|
+
this.resolvers.set(mapName, filtered);
|
|
7274
|
+
logger.debug(
|
|
7275
|
+
`Registered resolver '${resolver.name}' for map '${mapName}' with priority ${resolver.priority ?? 50}`
|
|
7276
|
+
);
|
|
7277
|
+
}
|
|
7278
|
+
/**
|
|
7279
|
+
* Unregister a resolver.
|
|
7280
|
+
*
|
|
7281
|
+
* @param mapName The map name
|
|
7282
|
+
* @param resolverName The resolver name to unregister
|
|
7283
|
+
* @param clientId Optional - only unregister if registered by this client
|
|
7284
|
+
*/
|
|
7285
|
+
unregister(mapName, resolverName, clientId) {
|
|
7286
|
+
const entries = this.resolvers.get(mapName);
|
|
7287
|
+
if (!entries) return false;
|
|
7288
|
+
const entryIndex = entries.findIndex(
|
|
7289
|
+
(e) => e.resolver.name === resolverName && (!clientId || e.registeredBy === clientId)
|
|
7290
|
+
);
|
|
7291
|
+
if (entryIndex === -1) return false;
|
|
7292
|
+
entries.splice(entryIndex, 1);
|
|
7293
|
+
if (entries.length === 0) {
|
|
7294
|
+
this.resolvers.delete(mapName);
|
|
7295
|
+
}
|
|
7296
|
+
logger.debug(`Unregistered resolver '${resolverName}' from map '${mapName}'`);
|
|
7297
|
+
return true;
|
|
7298
|
+
}
|
|
7299
|
+
/**
|
|
7300
|
+
* Resolve a merge conflict using registered resolvers.
|
|
7301
|
+
*
|
|
7302
|
+
* @param context The merge context
|
|
7303
|
+
* @returns The merge result
|
|
7304
|
+
*/
|
|
7305
|
+
async resolve(context) {
|
|
7306
|
+
if (this.disposed) {
|
|
7307
|
+
return { action: "accept", value: context.remoteValue };
|
|
7308
|
+
}
|
|
7309
|
+
const entries = this.resolvers.get(context.mapName) ?? [];
|
|
7310
|
+
const allEntries = [
|
|
7311
|
+
...entries,
|
|
7312
|
+
{ resolver: import_core13.BuiltInResolvers.LWW() }
|
|
7313
|
+
];
|
|
7314
|
+
for (const entry of allEntries) {
|
|
7315
|
+
const { resolver } = entry;
|
|
7316
|
+
if (resolver.keyPattern && !this.matchKeyPattern(context.key, resolver.keyPattern)) {
|
|
7317
|
+
continue;
|
|
7318
|
+
}
|
|
7319
|
+
try {
|
|
7320
|
+
let result;
|
|
7321
|
+
if (resolver.fn) {
|
|
7322
|
+
const fn = resolver.fn;
|
|
7323
|
+
const maybePromise = fn(context);
|
|
7324
|
+
result = maybePromise instanceof Promise ? await maybePromise : maybePromise;
|
|
7325
|
+
} else if (entry.compiledFn) {
|
|
7326
|
+
const compiledFn = entry.compiledFn;
|
|
7327
|
+
result = await compiledFn(context);
|
|
7328
|
+
} else {
|
|
7329
|
+
continue;
|
|
7330
|
+
}
|
|
7331
|
+
if (result.action !== "local") {
|
|
7332
|
+
if (result.action === "reject") {
|
|
7333
|
+
logger.debug(
|
|
7334
|
+
`Resolver '${resolver.name}' rejected merge for key '${context.key}' in map '${context.mapName}': ${result.reason}`
|
|
7335
|
+
);
|
|
7336
|
+
if (this.onRejectionCallback) {
|
|
7337
|
+
this.onRejectionCallback({
|
|
7338
|
+
mapName: context.mapName,
|
|
7339
|
+
key: context.key,
|
|
7340
|
+
attemptedValue: context.remoteValue,
|
|
7341
|
+
reason: result.reason,
|
|
7342
|
+
timestamp: context.remoteTimestamp,
|
|
7343
|
+
nodeId: context.remoteNodeId
|
|
7344
|
+
});
|
|
7345
|
+
}
|
|
7346
|
+
}
|
|
7347
|
+
return result;
|
|
7348
|
+
}
|
|
7349
|
+
} catch (error) {
|
|
7350
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
7351
|
+
logger.error(`Resolver '${resolver.name}' threw error: ${message}`);
|
|
7352
|
+
}
|
|
7353
|
+
}
|
|
7354
|
+
return { action: "accept", value: context.remoteValue };
|
|
7355
|
+
}
|
|
7356
|
+
/**
|
|
7357
|
+
* List registered resolvers.
|
|
7358
|
+
*
|
|
7359
|
+
* @param mapName Optional - filter by map name
|
|
7360
|
+
*/
|
|
7361
|
+
list(mapName) {
|
|
7362
|
+
const result = [];
|
|
7363
|
+
if (mapName) {
|
|
7364
|
+
const entries = this.resolvers.get(mapName) ?? [];
|
|
7365
|
+
for (const entry of entries) {
|
|
7366
|
+
result.push({
|
|
7367
|
+
mapName,
|
|
7368
|
+
name: entry.resolver.name,
|
|
7369
|
+
priority: entry.resolver.priority,
|
|
7370
|
+
keyPattern: entry.resolver.keyPattern,
|
|
7371
|
+
registeredBy: entry.registeredBy
|
|
7372
|
+
});
|
|
7373
|
+
}
|
|
7374
|
+
} else {
|
|
7375
|
+
for (const [map, entries] of this.resolvers.entries()) {
|
|
7376
|
+
for (const entry of entries) {
|
|
7377
|
+
result.push({
|
|
7378
|
+
mapName: map,
|
|
7379
|
+
name: entry.resolver.name,
|
|
7380
|
+
priority: entry.resolver.priority,
|
|
7381
|
+
keyPattern: entry.resolver.keyPattern,
|
|
7382
|
+
registeredBy: entry.registeredBy
|
|
7383
|
+
});
|
|
7384
|
+
}
|
|
7385
|
+
}
|
|
7386
|
+
}
|
|
7387
|
+
return result;
|
|
7388
|
+
}
|
|
7389
|
+
/**
|
|
7390
|
+
* Check if a map has any registered resolvers.
|
|
7391
|
+
*/
|
|
7392
|
+
hasResolvers(mapName) {
|
|
7393
|
+
const entries = this.resolvers.get(mapName);
|
|
7394
|
+
return entries !== void 0 && entries.length > 0;
|
|
7395
|
+
}
|
|
7396
|
+
/**
|
|
7397
|
+
* Get the number of registered resolvers.
|
|
7398
|
+
*/
|
|
7399
|
+
get size() {
|
|
7400
|
+
let count = 0;
|
|
7401
|
+
for (const entries of this.resolvers.values()) {
|
|
7402
|
+
count += entries.length;
|
|
7403
|
+
}
|
|
7404
|
+
return count;
|
|
7405
|
+
}
|
|
7406
|
+
/**
|
|
7407
|
+
* Clear all registered resolvers.
|
|
7408
|
+
*
|
|
7409
|
+
* @param mapName Optional - only clear resolvers for specific map
|
|
7410
|
+
*/
|
|
7411
|
+
clear(mapName) {
|
|
7412
|
+
if (mapName) {
|
|
7413
|
+
this.resolvers.delete(mapName);
|
|
7414
|
+
} else {
|
|
7415
|
+
this.resolvers.clear();
|
|
7416
|
+
}
|
|
7417
|
+
}
|
|
7418
|
+
/**
|
|
7419
|
+
* Clear resolvers registered by a specific client.
|
|
7420
|
+
*/
|
|
7421
|
+
clearByClient(clientId) {
|
|
7422
|
+
let removed = 0;
|
|
7423
|
+
for (const [mapName, entries] of this.resolvers.entries()) {
|
|
7424
|
+
const before = entries.length;
|
|
7425
|
+
const filtered = entries.filter((e) => e.registeredBy !== clientId);
|
|
7426
|
+
removed += before - filtered.length;
|
|
7427
|
+
if (filtered.length === 0) {
|
|
7428
|
+
this.resolvers.delete(mapName);
|
|
7429
|
+
} else if (filtered.length !== before) {
|
|
7430
|
+
this.resolvers.set(mapName, filtered);
|
|
7431
|
+
}
|
|
7432
|
+
}
|
|
7433
|
+
return removed;
|
|
7434
|
+
}
|
|
7435
|
+
/**
|
|
7436
|
+
* Dispose the service.
|
|
7437
|
+
*/
|
|
7438
|
+
dispose() {
|
|
7439
|
+
if (this.disposed) return;
|
|
7440
|
+
this.disposed = true;
|
|
7441
|
+
this.resolvers.clear();
|
|
7442
|
+
logger.debug("ConflictResolverService disposed");
|
|
7443
|
+
}
|
|
7444
|
+
/**
|
|
7445
|
+
* Match a key against a glob-like pattern.
|
|
7446
|
+
* Supports * (any chars) and ? (single char).
|
|
7447
|
+
*/
|
|
7448
|
+
matchKeyPattern(key, pattern) {
|
|
7449
|
+
const regexPattern = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
7450
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
7451
|
+
return regex.test(key);
|
|
7452
|
+
}
|
|
7453
|
+
/**
|
|
7454
|
+
* Compile sandboxed resolver code.
|
|
7455
|
+
*/
|
|
7456
|
+
compileSandboxed(name, code) {
|
|
7457
|
+
return async (ctx) => {
|
|
7458
|
+
const wrappedCode = `
|
|
7459
|
+
const context = {
|
|
7460
|
+
mapName: ${JSON.stringify(ctx.mapName)},
|
|
7461
|
+
key: ${JSON.stringify(ctx.key)},
|
|
7462
|
+
localValue: ${JSON.stringify(ctx.localValue)},
|
|
7463
|
+
remoteValue: ${JSON.stringify(ctx.remoteValue)},
|
|
7464
|
+
localTimestamp: ${JSON.stringify(ctx.localTimestamp)},
|
|
7465
|
+
remoteTimestamp: ${JSON.stringify(ctx.remoteTimestamp)},
|
|
7466
|
+
remoteNodeId: ${JSON.stringify(ctx.remoteNodeId)},
|
|
7467
|
+
auth: ${JSON.stringify(ctx.auth)},
|
|
7468
|
+
};
|
|
7469
|
+
|
|
7470
|
+
function resolve(context) {
|
|
7471
|
+
${code}
|
|
7472
|
+
}
|
|
7473
|
+
|
|
7474
|
+
const result = resolve(context);
|
|
7475
|
+
return { value: result, result };
|
|
7476
|
+
`;
|
|
7477
|
+
const result = await this.sandbox.execute(
|
|
7478
|
+
{
|
|
7479
|
+
name: `resolver:${name}`,
|
|
7480
|
+
code: wrappedCode
|
|
7481
|
+
},
|
|
7482
|
+
null,
|
|
7483
|
+
// value parameter unused for resolvers
|
|
7484
|
+
"resolver"
|
|
7485
|
+
);
|
|
7486
|
+
if (!result.success) {
|
|
7487
|
+
throw new Error(result.error || "Resolver execution failed");
|
|
7488
|
+
}
|
|
7489
|
+
const resolverResult = result.result;
|
|
7490
|
+
if (!resolverResult || typeof resolverResult !== "object") {
|
|
7491
|
+
throw new Error("Resolver must return a result object");
|
|
7492
|
+
}
|
|
7493
|
+
const action = resolverResult.action;
|
|
7494
|
+
if (!["accept", "reject", "merge", "local"].includes(action)) {
|
|
7495
|
+
throw new Error(`Invalid resolver action: ${action}`);
|
|
7496
|
+
}
|
|
7497
|
+
return resolverResult;
|
|
7498
|
+
};
|
|
7499
|
+
}
|
|
7500
|
+
};
|
|
7501
|
+
|
|
7502
|
+
// src/handlers/ConflictResolverHandler.ts
|
|
7503
|
+
var ConflictResolverHandler = class {
|
|
7504
|
+
constructor(config) {
|
|
7505
|
+
this.rejectionListeners = /* @__PURE__ */ new Set();
|
|
7506
|
+
this.nodeId = config.nodeId;
|
|
7507
|
+
this.sandbox = new ProcessorSandbox(config.sandboxConfig);
|
|
7508
|
+
this.resolverService = new ConflictResolverService(
|
|
7509
|
+
this.sandbox,
|
|
7510
|
+
config.resolverConfig
|
|
7511
|
+
);
|
|
7512
|
+
this.resolverService.onRejection((rejection) => {
|
|
7513
|
+
for (const listener of this.rejectionListeners) {
|
|
7514
|
+
try {
|
|
7515
|
+
listener(rejection);
|
|
7516
|
+
} catch (e) {
|
|
7517
|
+
logger.error({ error: e }, "Error in rejection listener");
|
|
7518
|
+
}
|
|
7519
|
+
}
|
|
7520
|
+
});
|
|
7521
|
+
}
|
|
7522
|
+
/**
|
|
7523
|
+
* Register a conflict resolver for a map.
|
|
7524
|
+
*
|
|
7525
|
+
* @param mapName The map name
|
|
7526
|
+
* @param resolver The resolver definition
|
|
7527
|
+
* @param clientId Optional client ID that registered this resolver
|
|
7528
|
+
*/
|
|
7529
|
+
registerResolver(mapName, resolver, clientId) {
|
|
7530
|
+
this.resolverService.register(mapName, resolver, clientId);
|
|
7531
|
+
logger.info(
|
|
7532
|
+
{
|
|
7533
|
+
mapName,
|
|
7534
|
+
resolverName: resolver.name,
|
|
7535
|
+
priority: resolver.priority,
|
|
7536
|
+
clientId
|
|
7537
|
+
},
|
|
7538
|
+
"Resolver registered"
|
|
7539
|
+
);
|
|
7540
|
+
}
|
|
7541
|
+
/**
|
|
7542
|
+
* Unregister a conflict resolver.
|
|
7543
|
+
*
|
|
7544
|
+
* @param mapName The map name
|
|
7545
|
+
* @param resolverName The resolver name
|
|
7546
|
+
* @param clientId Optional - only unregister if registered by this client
|
|
7547
|
+
*/
|
|
7548
|
+
unregisterResolver(mapName, resolverName, clientId) {
|
|
7549
|
+
const removed = this.resolverService.unregister(
|
|
7550
|
+
mapName,
|
|
7551
|
+
resolverName,
|
|
7552
|
+
clientId
|
|
7553
|
+
);
|
|
7554
|
+
if (removed) {
|
|
7555
|
+
logger.info({ mapName, resolverName, clientId }, "Resolver unregistered");
|
|
7556
|
+
}
|
|
7557
|
+
return removed;
|
|
7558
|
+
}
|
|
7559
|
+
/**
|
|
7560
|
+
* List registered resolvers.
|
|
7561
|
+
*
|
|
7562
|
+
* @param mapName Optional - filter by map name
|
|
7563
|
+
*/
|
|
7564
|
+
listResolvers(mapName) {
|
|
7565
|
+
return this.resolverService.list(mapName);
|
|
7566
|
+
}
|
|
7567
|
+
/**
|
|
7568
|
+
* Apply a merge with conflict resolution.
|
|
7569
|
+
*
|
|
7570
|
+
* Deletions (tombstones) are also passed through resolvers to allow
|
|
7571
|
+
* protection via IMMUTABLE, OWNER_ONLY, or similar resolvers.
|
|
7572
|
+
* If no custom resolvers are registered, deletions use standard LWW.
|
|
7573
|
+
*
|
|
7574
|
+
* @param map The LWWMap to merge into
|
|
7575
|
+
* @param mapName The map name (for resolver lookup)
|
|
7576
|
+
* @param key The key being merged
|
|
7577
|
+
* @param record The incoming record
|
|
7578
|
+
* @param remoteNodeId The source node ID
|
|
7579
|
+
* @param auth Optional authentication context
|
|
7580
|
+
*/
|
|
7581
|
+
async mergeWithResolver(map, mapName, key, record, remoteNodeId, auth) {
|
|
7582
|
+
const isDeletion = record.value === null;
|
|
7583
|
+
const localRecord = map.getRecord(key);
|
|
7584
|
+
const context = {
|
|
7585
|
+
mapName,
|
|
7586
|
+
key,
|
|
7587
|
+
localValue: localRecord?.value ?? void 0,
|
|
7588
|
+
// For deletions, remoteValue is null - resolvers can check this
|
|
7589
|
+
remoteValue: record.value,
|
|
7590
|
+
localTimestamp: localRecord?.timestamp,
|
|
7591
|
+
remoteTimestamp: record.timestamp,
|
|
7592
|
+
remoteNodeId,
|
|
7593
|
+
auth,
|
|
7594
|
+
readEntry: (k) => map.get(k)
|
|
7595
|
+
};
|
|
7596
|
+
const result = await this.resolverService.resolve(context);
|
|
7597
|
+
switch (result.action) {
|
|
7598
|
+
case "accept":
|
|
7599
|
+
case "merge": {
|
|
7600
|
+
const finalValue = isDeletion ? null : result.value;
|
|
7601
|
+
const finalRecord = {
|
|
7602
|
+
value: finalValue,
|
|
7603
|
+
timestamp: record.timestamp,
|
|
7604
|
+
ttlMs: record.ttlMs
|
|
7605
|
+
};
|
|
7606
|
+
map.merge(key, finalRecord);
|
|
7607
|
+
return { applied: true, result, record: finalRecord };
|
|
7608
|
+
}
|
|
7609
|
+
case "reject": {
|
|
7610
|
+
const rejection = {
|
|
7611
|
+
mapName,
|
|
7612
|
+
key,
|
|
7613
|
+
attemptedValue: record.value,
|
|
7614
|
+
reason: result.reason,
|
|
7615
|
+
timestamp: record.timestamp,
|
|
7616
|
+
nodeId: remoteNodeId
|
|
7617
|
+
};
|
|
7618
|
+
return { applied: false, result, rejection };
|
|
7619
|
+
}
|
|
7620
|
+
case "local":
|
|
7621
|
+
default:
|
|
7622
|
+
return { applied: false, result };
|
|
7623
|
+
}
|
|
7624
|
+
}
|
|
7625
|
+
/**
|
|
7626
|
+
* Check if a map has custom resolvers registered.
|
|
7627
|
+
*/
|
|
7628
|
+
hasResolvers(mapName) {
|
|
7629
|
+
return this.resolverService.hasResolvers(mapName);
|
|
7630
|
+
}
|
|
7631
|
+
/**
|
|
7632
|
+
* Add a listener for merge rejections.
|
|
7633
|
+
*/
|
|
7634
|
+
onRejection(listener) {
|
|
7635
|
+
this.rejectionListeners.add(listener);
|
|
7636
|
+
return () => this.rejectionListeners.delete(listener);
|
|
7637
|
+
}
|
|
7638
|
+
/**
|
|
7639
|
+
* Clear resolvers registered by a specific client.
|
|
7640
|
+
*/
|
|
7641
|
+
clearByClient(clientId) {
|
|
7642
|
+
return this.resolverService.clearByClient(clientId);
|
|
7643
|
+
}
|
|
7644
|
+
/**
|
|
7645
|
+
* Get the number of registered resolvers.
|
|
7646
|
+
*/
|
|
7647
|
+
get resolverCount() {
|
|
7648
|
+
return this.resolverService.size;
|
|
7649
|
+
}
|
|
7650
|
+
/**
|
|
7651
|
+
* Check if sandbox is in secure mode.
|
|
7652
|
+
*/
|
|
7653
|
+
isSecureMode() {
|
|
7654
|
+
return this.sandbox.isSecureMode();
|
|
7655
|
+
}
|
|
7656
|
+
/**
|
|
7657
|
+
* Dispose of the handler.
|
|
7658
|
+
*/
|
|
7659
|
+
dispose() {
|
|
7660
|
+
this.resolverService.dispose();
|
|
7661
|
+
this.sandbox.dispose();
|
|
7662
|
+
this.rejectionListeners.clear();
|
|
7663
|
+
logger.debug("ConflictResolverHandler disposed");
|
|
7664
|
+
}
|
|
7665
|
+
};
|
|
7666
|
+
|
|
7667
|
+
// src/EventJournalService.ts
|
|
7668
|
+
var import_core14 = require("@topgunbuild/core");
|
|
7669
|
+
var DEFAULT_JOURNAL_SERVICE_CONFIG = {
|
|
7670
|
+
...import_core14.DEFAULT_EVENT_JOURNAL_CONFIG,
|
|
7671
|
+
tableName: "event_journal",
|
|
7672
|
+
persistBatchSize: 100,
|
|
7673
|
+
persistIntervalMs: 1e3
|
|
7674
|
+
};
|
|
7675
|
+
var TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
7676
|
+
function validateTableName(name) {
|
|
7677
|
+
if (!TABLE_NAME_REGEX.test(name)) {
|
|
7678
|
+
throw new Error(
|
|
7679
|
+
`Invalid table name "${name}". Table name must start with a letter or underscore and contain only alphanumeric characters and underscores.`
|
|
7680
|
+
);
|
|
7681
|
+
}
|
|
7682
|
+
}
|
|
7683
|
+
var EventJournalService = class extends import_core14.EventJournalImpl {
|
|
7684
|
+
constructor(config) {
|
|
7685
|
+
super(config);
|
|
7686
|
+
this.pendingPersist = [];
|
|
7687
|
+
this.isPersisting = false;
|
|
7688
|
+
this.isInitialized = false;
|
|
7689
|
+
this.isLoadingFromStorage = false;
|
|
7690
|
+
this.pool = config.pool;
|
|
7691
|
+
this.tableName = config.tableName ?? DEFAULT_JOURNAL_SERVICE_CONFIG.tableName;
|
|
7692
|
+
this.persistBatchSize = config.persistBatchSize ?? DEFAULT_JOURNAL_SERVICE_CONFIG.persistBatchSize;
|
|
7693
|
+
this.persistIntervalMs = config.persistIntervalMs ?? DEFAULT_JOURNAL_SERVICE_CONFIG.persistIntervalMs;
|
|
7694
|
+
validateTableName(this.tableName);
|
|
7695
|
+
this.subscribe((event) => {
|
|
7696
|
+
if (this.isLoadingFromStorage) return;
|
|
7697
|
+
if (event.sequence >= 0n && this.getConfig().persistent) {
|
|
7698
|
+
this.pendingPersist.push(event);
|
|
7699
|
+
if (this.pendingPersist.length >= this.persistBatchSize) {
|
|
7700
|
+
this.persistToStorage().catch((err) => {
|
|
7701
|
+
logger.error({ err }, "Failed to persist journal events");
|
|
7702
|
+
});
|
|
7703
|
+
}
|
|
7704
|
+
}
|
|
7705
|
+
});
|
|
7706
|
+
this.startPersistTimer();
|
|
7707
|
+
}
|
|
7708
|
+
/**
|
|
7709
|
+
* Initialize the journal service, creating table if needed.
|
|
7710
|
+
*/
|
|
7711
|
+
async initialize() {
|
|
7712
|
+
if (this.isInitialized) return;
|
|
7713
|
+
const client = await this.pool.connect();
|
|
7714
|
+
try {
|
|
7715
|
+
await client.query(`
|
|
7716
|
+
CREATE TABLE IF NOT EXISTS ${this.tableName} (
|
|
7717
|
+
sequence BIGINT PRIMARY KEY,
|
|
7718
|
+
type VARCHAR(10) NOT NULL CHECK (type IN ('PUT', 'UPDATE', 'DELETE')),
|
|
7719
|
+
map_name VARCHAR(255) NOT NULL,
|
|
7720
|
+
key VARCHAR(1024) NOT NULL,
|
|
7721
|
+
value JSONB,
|
|
7722
|
+
previous_value JSONB,
|
|
7723
|
+
timestamp JSONB NOT NULL,
|
|
7724
|
+
node_id VARCHAR(64) NOT NULL,
|
|
7725
|
+
metadata JSONB,
|
|
7726
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
7727
|
+
);
|
|
7728
|
+
`);
|
|
7729
|
+
await client.query(`
|
|
7730
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_map_name
|
|
7731
|
+
ON ${this.tableName}(map_name);
|
|
7732
|
+
`);
|
|
7733
|
+
await client.query(`
|
|
7734
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_key
|
|
7735
|
+
ON ${this.tableName}(map_name, key);
|
|
7736
|
+
`);
|
|
7737
|
+
await client.query(`
|
|
7738
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_created_at
|
|
7739
|
+
ON ${this.tableName}(created_at);
|
|
7740
|
+
`);
|
|
7741
|
+
await client.query(`
|
|
7742
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_node_id
|
|
7743
|
+
ON ${this.tableName}(node_id);
|
|
7744
|
+
`);
|
|
7745
|
+
this.isInitialized = true;
|
|
7746
|
+
logger.info({ tableName: this.tableName }, "EventJournalService initialized");
|
|
7747
|
+
} finally {
|
|
7748
|
+
client.release();
|
|
7749
|
+
}
|
|
7750
|
+
}
|
|
7751
|
+
/**
|
|
7752
|
+
* Persist pending events to PostgreSQL.
|
|
7753
|
+
*/
|
|
7754
|
+
async persistToStorage() {
|
|
7755
|
+
if (this.pendingPersist.length === 0 || this.isPersisting) return;
|
|
7756
|
+
this.isPersisting = true;
|
|
7757
|
+
const batch = this.pendingPersist.splice(0, this.persistBatchSize);
|
|
7758
|
+
try {
|
|
7759
|
+
if (batch.length === 0) return;
|
|
7760
|
+
const values = [];
|
|
7761
|
+
const placeholders = [];
|
|
7762
|
+
batch.forEach((e, i) => {
|
|
7763
|
+
const offset = i * 9;
|
|
7764
|
+
placeholders.push(
|
|
7765
|
+
`($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, $${offset + 6}, $${offset + 7}, $${offset + 8}, $${offset + 9})`
|
|
7766
|
+
);
|
|
7767
|
+
values.push(
|
|
7768
|
+
e.sequence.toString(),
|
|
7769
|
+
e.type,
|
|
7770
|
+
e.mapName,
|
|
7771
|
+
e.key,
|
|
7772
|
+
e.value !== void 0 ? JSON.stringify(e.value) : null,
|
|
7773
|
+
e.previousValue !== void 0 ? JSON.stringify(e.previousValue) : null,
|
|
7774
|
+
JSON.stringify(e.timestamp),
|
|
7775
|
+
e.nodeId,
|
|
7776
|
+
e.metadata ? JSON.stringify(e.metadata) : null
|
|
7777
|
+
);
|
|
7778
|
+
});
|
|
7779
|
+
await this.pool.query(
|
|
7780
|
+
`INSERT INTO ${this.tableName}
|
|
7781
|
+
(sequence, type, map_name, key, value, previous_value, timestamp, node_id, metadata)
|
|
7782
|
+
VALUES ${placeholders.join(", ")}
|
|
7783
|
+
ON CONFLICT (sequence) DO NOTHING`,
|
|
7784
|
+
values
|
|
7785
|
+
);
|
|
7786
|
+
logger.debug({ count: batch.length }, "Persisted journal events");
|
|
7787
|
+
} catch (error) {
|
|
7788
|
+
this.pendingPersist.unshift(...batch);
|
|
7789
|
+
throw error;
|
|
7790
|
+
} finally {
|
|
7791
|
+
this.isPersisting = false;
|
|
7792
|
+
}
|
|
7793
|
+
}
|
|
7794
|
+
/**
|
|
7795
|
+
* Load journal events from PostgreSQL on startup.
|
|
7796
|
+
*/
|
|
7797
|
+
async loadFromStorage() {
|
|
7798
|
+
const config = this.getConfig();
|
|
7799
|
+
const result = await this.pool.query(
|
|
7800
|
+
`SELECT sequence, type, map_name, key, value, previous_value, timestamp, node_id, metadata
|
|
7801
|
+
FROM ${this.tableName}
|
|
7802
|
+
ORDER BY sequence DESC
|
|
7803
|
+
LIMIT $1`,
|
|
7804
|
+
[config.capacity]
|
|
7805
|
+
);
|
|
7806
|
+
const events = result.rows.reverse();
|
|
7807
|
+
this.isLoadingFromStorage = true;
|
|
7808
|
+
try {
|
|
7809
|
+
for (const row of events) {
|
|
7810
|
+
this.append({
|
|
7811
|
+
type: row.type,
|
|
7812
|
+
mapName: row.map_name,
|
|
7813
|
+
key: row.key,
|
|
7814
|
+
value: row.value,
|
|
7815
|
+
previousValue: row.previous_value,
|
|
7816
|
+
timestamp: typeof row.timestamp === "string" ? JSON.parse(row.timestamp) : row.timestamp,
|
|
7817
|
+
nodeId: row.node_id,
|
|
7818
|
+
metadata: row.metadata
|
|
7819
|
+
});
|
|
7820
|
+
}
|
|
7821
|
+
} finally {
|
|
7822
|
+
this.isLoadingFromStorage = false;
|
|
7823
|
+
}
|
|
7824
|
+
logger.info({ count: events.length }, "Loaded journal events from storage");
|
|
7825
|
+
}
|
|
7826
|
+
/**
|
|
7827
|
+
* Export events as NDJSON stream.
|
|
7828
|
+
*/
|
|
7829
|
+
exportStream(options = {}) {
|
|
7830
|
+
const self = this;
|
|
7831
|
+
return new ReadableStream({
|
|
7832
|
+
start(controller) {
|
|
7833
|
+
const startSeq = options.fromSequence ?? self.getOldestSequence();
|
|
7834
|
+
const endSeq = options.toSequence ?? self.getLatestSequence();
|
|
7835
|
+
for (let seq = startSeq; seq <= endSeq; seq++) {
|
|
7836
|
+
const events = self.readFrom(seq, 1);
|
|
7837
|
+
if (events.length > 0) {
|
|
7838
|
+
const event = events[0];
|
|
7839
|
+
if (options.mapName && event.mapName !== options.mapName) continue;
|
|
7840
|
+
if (options.types && !options.types.includes(event.type)) continue;
|
|
7841
|
+
const serializable = {
|
|
7842
|
+
...event,
|
|
7843
|
+
sequence: event.sequence.toString()
|
|
7844
|
+
};
|
|
7845
|
+
controller.enqueue(JSON.stringify(serializable) + "\n");
|
|
7846
|
+
}
|
|
7847
|
+
}
|
|
7848
|
+
controller.close();
|
|
7849
|
+
}
|
|
7850
|
+
});
|
|
7851
|
+
}
|
|
7852
|
+
/**
|
|
7853
|
+
* Get events for a specific map.
|
|
7854
|
+
*/
|
|
7855
|
+
getMapEvents(mapName, fromSeq) {
|
|
7856
|
+
const events = this.readFrom(fromSeq ?? this.getOldestSequence(), this.getConfig().capacity);
|
|
7857
|
+
return events.filter((e) => e.mapName === mapName);
|
|
7858
|
+
}
|
|
7859
|
+
/**
|
|
7860
|
+
* Query events from PostgreSQL with filters.
|
|
7861
|
+
*/
|
|
7862
|
+
async queryFromStorage(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.key) {
|
|
7871
|
+
conditions.push(`key = $${paramIndex++}`);
|
|
7872
|
+
params.push(options.key);
|
|
7873
|
+
}
|
|
7874
|
+
if (options.types && options.types.length > 0) {
|
|
7875
|
+
conditions.push(`type = ANY($${paramIndex++})`);
|
|
7876
|
+
params.push(options.types);
|
|
7877
|
+
}
|
|
7878
|
+
if (options.fromSequence !== void 0) {
|
|
7879
|
+
conditions.push(`sequence >= $${paramIndex++}`);
|
|
7880
|
+
params.push(options.fromSequence.toString());
|
|
7881
|
+
}
|
|
7882
|
+
if (options.toSequence !== void 0) {
|
|
7883
|
+
conditions.push(`sequence <= $${paramIndex++}`);
|
|
7884
|
+
params.push(options.toSequence.toString());
|
|
7885
|
+
}
|
|
7886
|
+
if (options.fromDate) {
|
|
7887
|
+
conditions.push(`created_at >= $${paramIndex++}`);
|
|
7888
|
+
params.push(options.fromDate);
|
|
7889
|
+
}
|
|
7890
|
+
if (options.toDate) {
|
|
7891
|
+
conditions.push(`created_at <= $${paramIndex++}`);
|
|
7892
|
+
params.push(options.toDate);
|
|
7893
|
+
}
|
|
7894
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
7895
|
+
const limit = options.limit ?? 100;
|
|
7896
|
+
const offset = options.offset ?? 0;
|
|
7897
|
+
const result = await this.pool.query(
|
|
7898
|
+
`SELECT sequence, type, map_name, key, value, previous_value, timestamp, node_id, metadata
|
|
7899
|
+
FROM ${this.tableName}
|
|
7900
|
+
${whereClause}
|
|
7901
|
+
ORDER BY sequence ASC
|
|
7902
|
+
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
|
7903
|
+
[...params, limit, offset]
|
|
7904
|
+
);
|
|
7905
|
+
return result.rows.map((row) => ({
|
|
7906
|
+
sequence: BigInt(row.sequence),
|
|
7907
|
+
type: row.type,
|
|
7908
|
+
mapName: row.map_name,
|
|
7909
|
+
key: row.key,
|
|
7910
|
+
value: row.value,
|
|
7911
|
+
previousValue: row.previous_value,
|
|
7912
|
+
timestamp: typeof row.timestamp === "string" ? JSON.parse(row.timestamp) : row.timestamp,
|
|
7913
|
+
nodeId: row.node_id,
|
|
7914
|
+
metadata: row.metadata
|
|
7915
|
+
}));
|
|
7916
|
+
}
|
|
7917
|
+
/**
|
|
7918
|
+
* Count events matching filters.
|
|
7919
|
+
*/
|
|
7920
|
+
async countFromStorage(options = {}) {
|
|
7921
|
+
const conditions = [];
|
|
7922
|
+
const params = [];
|
|
7923
|
+
let paramIndex = 1;
|
|
7924
|
+
if (options.mapName) {
|
|
7925
|
+
conditions.push(`map_name = $${paramIndex++}`);
|
|
7926
|
+
params.push(options.mapName);
|
|
7927
|
+
}
|
|
7928
|
+
if (options.types && options.types.length > 0) {
|
|
7929
|
+
conditions.push(`type = ANY($${paramIndex++})`);
|
|
7930
|
+
params.push(options.types);
|
|
7931
|
+
}
|
|
7932
|
+
if (options.fromDate) {
|
|
7933
|
+
conditions.push(`created_at >= $${paramIndex++}`);
|
|
7934
|
+
params.push(options.fromDate);
|
|
7935
|
+
}
|
|
7936
|
+
if (options.toDate) {
|
|
7937
|
+
conditions.push(`created_at <= $${paramIndex++}`);
|
|
7938
|
+
params.push(options.toDate);
|
|
7939
|
+
}
|
|
7940
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
7941
|
+
const result = await this.pool.query(
|
|
7942
|
+
`SELECT COUNT(*) as count FROM ${this.tableName} ${whereClause}`,
|
|
7943
|
+
params
|
|
7944
|
+
);
|
|
7945
|
+
return parseInt(result.rows[0].count, 10);
|
|
7946
|
+
}
|
|
7947
|
+
/**
|
|
7948
|
+
* Cleanup old events based on retention policy.
|
|
7949
|
+
*/
|
|
7950
|
+
async cleanupOldEvents(retentionDays) {
|
|
7951
|
+
const result = await this.pool.query(
|
|
7952
|
+
`DELETE FROM ${this.tableName}
|
|
7953
|
+
WHERE created_at < NOW() - ($1 || ' days')::INTERVAL
|
|
7954
|
+
RETURNING sequence`,
|
|
7955
|
+
[retentionDays]
|
|
7956
|
+
);
|
|
7957
|
+
const count = result.rowCount ?? 0;
|
|
7958
|
+
if (count > 0) {
|
|
7959
|
+
logger.info({ deletedCount: count, retentionDays }, "Cleaned up old journal events");
|
|
7960
|
+
}
|
|
7961
|
+
return count;
|
|
7962
|
+
}
|
|
7963
|
+
/**
|
|
7964
|
+
* Start the periodic persistence timer.
|
|
7965
|
+
*/
|
|
7966
|
+
startPersistTimer() {
|
|
7967
|
+
this.persistTimer = setInterval(() => {
|
|
7968
|
+
if (this.pendingPersist.length > 0) {
|
|
7969
|
+
this.persistToStorage().catch((err) => {
|
|
7970
|
+
logger.error({ err }, "Periodic persist failed");
|
|
7971
|
+
});
|
|
7972
|
+
}
|
|
7973
|
+
}, this.persistIntervalMs);
|
|
7974
|
+
}
|
|
7975
|
+
/**
|
|
7976
|
+
* Stop the periodic persistence timer.
|
|
7977
|
+
*/
|
|
7978
|
+
stopPersistTimer() {
|
|
7979
|
+
if (this.persistTimer) {
|
|
7980
|
+
clearInterval(this.persistTimer);
|
|
7981
|
+
this.persistTimer = void 0;
|
|
7982
|
+
}
|
|
7983
|
+
}
|
|
7984
|
+
/**
|
|
7985
|
+
* Dispose resources and persist remaining events.
|
|
7986
|
+
*/
|
|
7987
|
+
dispose() {
|
|
7988
|
+
this.stopPersistTimer();
|
|
7989
|
+
if (this.pendingPersist.length > 0) {
|
|
7990
|
+
this.persistToStorage().catch((err) => {
|
|
7991
|
+
logger.error({ err }, "Final persist failed on dispose");
|
|
7992
|
+
});
|
|
7993
|
+
}
|
|
7994
|
+
super.dispose();
|
|
7995
|
+
}
|
|
7996
|
+
/**
|
|
7997
|
+
* Get pending persist count (for monitoring).
|
|
7998
|
+
*/
|
|
7999
|
+
getPendingPersistCount() {
|
|
8000
|
+
return this.pendingPersist.length;
|
|
8001
|
+
}
|
|
8002
|
+
};
|
|
8003
|
+
|
|
8004
|
+
// src/ServerCoordinator.ts
|
|
8005
|
+
var GC_INTERVAL_MS = 60 * 60 * 1e3;
|
|
8006
|
+
var GC_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
8007
|
+
var CLIENT_HEARTBEAT_TIMEOUT_MS = 2e4;
|
|
8008
|
+
var CLIENT_HEARTBEAT_CHECK_INTERVAL_MS = 5e3;
|
|
8009
|
+
var ServerCoordinator = class {
|
|
8010
|
+
constructor(config) {
|
|
8011
|
+
this.clients = /* @__PURE__ */ new Map();
|
|
8012
|
+
// Interceptors
|
|
8013
|
+
this.interceptors = [];
|
|
8014
|
+
// In-memory storage (partitioned later)
|
|
8015
|
+
this.maps = /* @__PURE__ */ new Map();
|
|
8016
|
+
this.pendingClusterQueries = /* @__PURE__ */ new Map();
|
|
8017
|
+
// GC Consensus State
|
|
8018
|
+
this.gcReports = /* @__PURE__ */ new Map();
|
|
8019
|
+
// Track map loading state to avoid returning empty results during async load
|
|
8020
|
+
this.mapLoadingPromises = /* @__PURE__ */ new Map();
|
|
8021
|
+
// Track pending batch operations for testing purposes
|
|
8022
|
+
this.pendingBatchOperations = /* @__PURE__ */ new Set();
|
|
8023
|
+
this.journalSubscriptions = /* @__PURE__ */ new Map();
|
|
8024
|
+
this._actualPort = 0;
|
|
8025
|
+
this._actualClusterPort = 0;
|
|
8026
|
+
this._readyPromise = new Promise((resolve) => {
|
|
8027
|
+
this._readyResolve = resolve;
|
|
8028
|
+
});
|
|
8029
|
+
this._nodeId = config.nodeId;
|
|
8030
|
+
this.hlc = new import_core15.HLC(config.nodeId);
|
|
8031
|
+
this.storage = config.storage;
|
|
8032
|
+
const rawSecret = config.jwtSecret || process.env.JWT_SECRET || "topgun-secret-dev";
|
|
8033
|
+
this.jwtSecret = rawSecret.replace(/\\n/g, "\n");
|
|
8034
|
+
this.queryRegistry = new QueryRegistry();
|
|
8035
|
+
this.securityManager = new SecurityManager(config.securityPolicies || []);
|
|
8036
|
+
this.interceptors = config.interceptors || [];
|
|
8037
|
+
this.metricsService = new MetricsService();
|
|
8038
|
+
this.eventExecutor = new StripedEventExecutor({
|
|
8039
|
+
stripeCount: config.eventStripeCount ?? 4,
|
|
8040
|
+
queueCapacity: config.eventQueueCapacity ?? 1e4,
|
|
8041
|
+
name: `${config.nodeId}-event-executor`,
|
|
8042
|
+
onReject: (task) => {
|
|
8043
|
+
logger.warn({ nodeId: config.nodeId, key: task.key }, "Event task rejected due to queue capacity");
|
|
8044
|
+
this.metricsService.incEventQueueRejected();
|
|
8045
|
+
}
|
|
8046
|
+
});
|
|
8047
|
+
this.backpressure = new BackpressureRegulator({
|
|
8048
|
+
syncFrequency: config.backpressureSyncFrequency ?? 100,
|
|
8049
|
+
maxPendingOps: config.backpressureMaxPending ?? 1e3,
|
|
8050
|
+
backoffTimeoutMs: config.backpressureBackoffMs ?? 5e3,
|
|
8051
|
+
enabled: config.backpressureEnabled ?? true
|
|
8052
|
+
});
|
|
8053
|
+
this.writeCoalescingEnabled = config.writeCoalescingEnabled ?? true;
|
|
8054
|
+
const preset = coalescingPresets[config.writeCoalescingPreset ?? "highThroughput"];
|
|
8055
|
+
this.writeCoalescingOptions = {
|
|
8056
|
+
maxBatchSize: config.writeCoalescingMaxBatch ?? preset.maxBatchSize,
|
|
8057
|
+
maxDelayMs: config.writeCoalescingMaxDelayMs ?? preset.maxDelayMs,
|
|
8058
|
+
maxBatchBytes: config.writeCoalescingMaxBytes ?? preset.maxBatchBytes
|
|
8059
|
+
};
|
|
8060
|
+
this.eventPayloadPool = createEventPayloadPool({
|
|
8061
|
+
maxSize: 4096,
|
|
8062
|
+
initialSize: 128
|
|
8063
|
+
});
|
|
8064
|
+
this.taskletScheduler = new TaskletScheduler({
|
|
8065
|
+
defaultTimeBudgetMs: 5,
|
|
8066
|
+
maxConcurrent: 20
|
|
8067
|
+
});
|
|
8068
|
+
this.writeAckManager = new WriteAckManager({
|
|
8069
|
+
defaultTimeout: config.writeAckTimeout ?? 5e3
|
|
8070
|
+
});
|
|
8071
|
+
this.rateLimitingEnabled = config.rateLimitingEnabled ?? true;
|
|
8072
|
+
this.rateLimiter = new ConnectionRateLimiter({
|
|
8073
|
+
maxConnectionsPerSecond: config.maxConnectionsPerSecond ?? 100,
|
|
8074
|
+
maxPendingConnections: config.maxPendingConnections ?? 1e3,
|
|
8075
|
+
cooldownMs: 1e3
|
|
8076
|
+
});
|
|
8077
|
+
if (config.workerPoolEnabled) {
|
|
8078
|
+
this.workerPool = new WorkerPool({
|
|
8079
|
+
minWorkers: config.workerPoolConfig?.minWorkers ?? 2,
|
|
8080
|
+
maxWorkers: config.workerPoolConfig?.maxWorkers,
|
|
8081
|
+
taskTimeout: config.workerPoolConfig?.taskTimeout ?? 5e3,
|
|
8082
|
+
idleTimeout: config.workerPoolConfig?.idleTimeout ?? 3e4,
|
|
8083
|
+
autoRestart: config.workerPoolConfig?.autoRestart ?? true
|
|
8084
|
+
});
|
|
8085
|
+
this.merkleWorker = new MerkleWorker(this.workerPool);
|
|
8086
|
+
this.crdtMergeWorker = new CRDTMergeWorker(this.workerPool);
|
|
8087
|
+
this.serializationWorker = new SerializationWorker(this.workerPool);
|
|
8088
|
+
logger.info({
|
|
8089
|
+
minWorkers: config.workerPoolConfig?.minWorkers ?? 2,
|
|
8090
|
+
maxWorkers: config.workerPoolConfig?.maxWorkers ?? "auto"
|
|
8091
|
+
}, "Worker pool initialized for CPU-bound operations");
|
|
8092
|
+
}
|
|
8093
|
+
if (config.tls?.enabled) {
|
|
8094
|
+
const tlsOptions = this.buildTLSOptions(config.tls);
|
|
8095
|
+
this.httpServer = (0, import_https.createServer)(tlsOptions, (_req, res) => {
|
|
8096
|
+
res.writeHead(200);
|
|
8097
|
+
res.end("TopGun Server Running (Secure)");
|
|
8098
|
+
});
|
|
8099
|
+
logger.info("TLS enabled for client connections");
|
|
8100
|
+
} else {
|
|
8101
|
+
this.httpServer = (0, import_http.createServer)((_req, res) => {
|
|
8102
|
+
res.writeHead(200);
|
|
8103
|
+
res.end("TopGun Server Running");
|
|
8104
|
+
});
|
|
8105
|
+
if (process.env.NODE_ENV === "production") {
|
|
8106
|
+
logger.warn("\u26A0\uFE0F TLS is disabled! Client connections are NOT encrypted.");
|
|
8107
|
+
}
|
|
8108
|
+
}
|
|
8109
|
+
const metricsPort = config.metricsPort !== void 0 ? config.metricsPort : 9090;
|
|
8110
|
+
this.metricsServer = (0, import_http.createServer)(async (req, res) => {
|
|
8111
|
+
if (req.url === "/metrics") {
|
|
8112
|
+
try {
|
|
8113
|
+
res.setHeader("Content-Type", this.metricsService.getContentType());
|
|
8114
|
+
res.end(await this.metricsService.getMetrics());
|
|
8115
|
+
} catch (err) {
|
|
8116
|
+
res.statusCode = 500;
|
|
8117
|
+
res.end("Internal Server Error");
|
|
6583
8118
|
}
|
|
6584
8119
|
} else {
|
|
6585
8120
|
res.statusCode = 404;
|
|
@@ -6634,8 +8169,8 @@ var ServerCoordinator = class {
|
|
|
6634
8169
|
this.cluster,
|
|
6635
8170
|
this.partitionService,
|
|
6636
8171
|
{
|
|
6637
|
-
...
|
|
6638
|
-
defaultConsistency: config.defaultConsistency ??
|
|
8172
|
+
...import_core15.DEFAULT_REPLICATION_CONFIG,
|
|
8173
|
+
defaultConsistency: config.defaultConsistency ?? import_core15.ConsistencyLevel.EVENTUAL,
|
|
6639
8174
|
...config.replicationConfig
|
|
6640
8175
|
}
|
|
6641
8176
|
);
|
|
@@ -6656,6 +8191,27 @@ var ServerCoordinator = class {
|
|
|
6656
8191
|
}
|
|
6657
8192
|
}
|
|
6658
8193
|
});
|
|
8194
|
+
this.counterHandler = new CounterHandler(this._nodeId);
|
|
8195
|
+
this.entryProcessorHandler = new EntryProcessorHandler({ hlc: this.hlc });
|
|
8196
|
+
this.conflictResolverHandler = new ConflictResolverHandler({ nodeId: this._nodeId });
|
|
8197
|
+
this.conflictResolverHandler.onRejection((rejection) => {
|
|
8198
|
+
this.notifyMergeRejection(rejection);
|
|
8199
|
+
});
|
|
8200
|
+
if (config.eventJournalEnabled && this.storage && "pool" in this.storage) {
|
|
8201
|
+
const pool = this.storage.pool;
|
|
8202
|
+
this.eventJournalService = new EventJournalService({
|
|
8203
|
+
capacity: 1e4,
|
|
8204
|
+
ttlMs: 0,
|
|
8205
|
+
persistent: true,
|
|
8206
|
+
pool,
|
|
8207
|
+
...config.eventJournalConfig
|
|
8208
|
+
});
|
|
8209
|
+
this.eventJournalService.initialize().then(() => {
|
|
8210
|
+
logger.info("EventJournalService initialized");
|
|
8211
|
+
}).catch((err) => {
|
|
8212
|
+
logger.error({ err }, "Failed to initialize EventJournalService");
|
|
8213
|
+
});
|
|
8214
|
+
}
|
|
6659
8215
|
this.systemManager = new SystemManager(
|
|
6660
8216
|
this.cluster,
|
|
6661
8217
|
this.metricsService,
|
|
@@ -6761,7 +8317,7 @@ var ServerCoordinator = class {
|
|
|
6761
8317
|
this.metricsService.destroy();
|
|
6762
8318
|
this.wss.close();
|
|
6763
8319
|
logger.info(`Closing ${this.clients.size} client connections...`);
|
|
6764
|
-
const shutdownMsg = (0,
|
|
8320
|
+
const shutdownMsg = (0, import_core15.serialize)({ type: "SHUTDOWN_PENDING", retryAfter: 5e3 });
|
|
6765
8321
|
for (const client of this.clients.values()) {
|
|
6766
8322
|
try {
|
|
6767
8323
|
if (client.socket.readyState === import_ws3.WebSocket.OPEN) {
|
|
@@ -6815,6 +8371,10 @@ var ServerCoordinator = class {
|
|
|
6815
8371
|
this.eventPayloadPool.clear();
|
|
6816
8372
|
this.taskletScheduler.shutdown();
|
|
6817
8373
|
this.writeAckManager.shutdown();
|
|
8374
|
+
this.entryProcessorHandler.dispose();
|
|
8375
|
+
if (this.eventJournalService) {
|
|
8376
|
+
this.eventJournalService.dispose();
|
|
8377
|
+
}
|
|
6818
8378
|
logger.info("Server Coordinator shutdown complete.");
|
|
6819
8379
|
}
|
|
6820
8380
|
async handleConnection(ws) {
|
|
@@ -6882,7 +8442,7 @@ var ServerCoordinator = class {
|
|
|
6882
8442
|
buf = Buffer.from(message);
|
|
6883
8443
|
}
|
|
6884
8444
|
try {
|
|
6885
|
-
data = (0,
|
|
8445
|
+
data = (0, import_core15.deserialize)(buf);
|
|
6886
8446
|
} catch (e) {
|
|
6887
8447
|
try {
|
|
6888
8448
|
const text = Buffer.isBuffer(buf) ? buf.toString() : new TextDecoder().decode(buf);
|
|
@@ -6921,6 +8481,7 @@ var ServerCoordinator = class {
|
|
|
6921
8481
|
}
|
|
6922
8482
|
this.lockManager.handleClientDisconnect(clientId);
|
|
6923
8483
|
this.topicManager.unsubscribeAll(clientId);
|
|
8484
|
+
this.counterHandler.unsubscribeAll(clientId);
|
|
6924
8485
|
const members = this.cluster.getMembers();
|
|
6925
8486
|
for (const memberId of members) {
|
|
6926
8487
|
if (!this.cluster.isLocal(memberId)) {
|
|
@@ -6933,10 +8494,10 @@ var ServerCoordinator = class {
|
|
|
6933
8494
|
this.clients.delete(clientId);
|
|
6934
8495
|
this.metricsService.setConnectedClients(this.clients.size);
|
|
6935
8496
|
});
|
|
6936
|
-
ws.send((0,
|
|
8497
|
+
ws.send((0, import_core15.serialize)({ type: "AUTH_REQUIRED" }));
|
|
6937
8498
|
}
|
|
6938
8499
|
async handleMessage(client, rawMessage) {
|
|
6939
|
-
const parseResult =
|
|
8500
|
+
const parseResult = import_core15.MessageSchema.safeParse(rawMessage);
|
|
6940
8501
|
if (!parseResult.success) {
|
|
6941
8502
|
logger.error({ clientId: client.id, error: parseResult.error }, "Invalid message format from client");
|
|
6942
8503
|
client.writer.write({
|
|
@@ -7176,7 +8737,7 @@ var ServerCoordinator = class {
|
|
|
7176
8737
|
this.metricsService.incOp("GET", message.mapName);
|
|
7177
8738
|
try {
|
|
7178
8739
|
const mapForSync = await this.getMapAsync(message.mapName);
|
|
7179
|
-
if (mapForSync instanceof
|
|
8740
|
+
if (mapForSync instanceof import_core15.LWWMap) {
|
|
7180
8741
|
const tree = mapForSync.getMerkleTree();
|
|
7181
8742
|
const rootHash = tree.getRootHash();
|
|
7182
8743
|
client.writer.write({
|
|
@@ -7214,7 +8775,7 @@ var ServerCoordinator = class {
|
|
|
7214
8775
|
const { mapName, path } = message.payload;
|
|
7215
8776
|
try {
|
|
7216
8777
|
const mapForBucket = await this.getMapAsync(mapName);
|
|
7217
|
-
if (mapForBucket instanceof
|
|
8778
|
+
if (mapForBucket instanceof import_core15.LWWMap) {
|
|
7218
8779
|
const treeForBucket = mapForBucket.getMerkleTree();
|
|
7219
8780
|
const buckets = treeForBucket.getBuckets(path);
|
|
7220
8781
|
const node = treeForBucket.getNode(path);
|
|
@@ -7343,6 +8904,219 @@ var ServerCoordinator = class {
|
|
|
7343
8904
|
}
|
|
7344
8905
|
break;
|
|
7345
8906
|
}
|
|
8907
|
+
// ============ Phase 5.2: PN Counter Handlers ============
|
|
8908
|
+
case "COUNTER_REQUEST": {
|
|
8909
|
+
const { name } = message.payload;
|
|
8910
|
+
const response = this.counterHandler.handleCounterRequest(client.id, name);
|
|
8911
|
+
client.writer.write(response);
|
|
8912
|
+
logger.debug({ clientId: client.id, name }, "Counter request handled");
|
|
8913
|
+
break;
|
|
8914
|
+
}
|
|
8915
|
+
case "COUNTER_SYNC": {
|
|
8916
|
+
const { name, state } = message.payload;
|
|
8917
|
+
const result = this.counterHandler.handleCounterSync(client.id, name, state);
|
|
8918
|
+
client.writer.write(result.response);
|
|
8919
|
+
for (const targetClientId of result.broadcastTo) {
|
|
8920
|
+
const targetClient = this.clients.get(targetClientId);
|
|
8921
|
+
if (targetClient && targetClient.socket.readyState === import_ws3.WebSocket.OPEN) {
|
|
8922
|
+
targetClient.writer.write(result.broadcastMessage);
|
|
8923
|
+
}
|
|
8924
|
+
}
|
|
8925
|
+
logger.debug({ clientId: client.id, name, broadcastCount: result.broadcastTo.length }, "Counter sync handled");
|
|
8926
|
+
break;
|
|
8927
|
+
}
|
|
8928
|
+
// ============ Phase 5.03: Entry Processor Handlers ============
|
|
8929
|
+
case "ENTRY_PROCESS": {
|
|
8930
|
+
const { requestId, mapName, key, processor } = message;
|
|
8931
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
8932
|
+
client.writer.write({
|
|
8933
|
+
type: "ENTRY_PROCESS_RESPONSE",
|
|
8934
|
+
requestId,
|
|
8935
|
+
success: false,
|
|
8936
|
+
error: `Access Denied for map ${mapName}`
|
|
8937
|
+
}, true);
|
|
8938
|
+
break;
|
|
8939
|
+
}
|
|
8940
|
+
const entryMap = this.getMap(mapName);
|
|
8941
|
+
const { result, timestamp } = await this.entryProcessorHandler.executeOnKey(
|
|
8942
|
+
entryMap,
|
|
8943
|
+
key,
|
|
8944
|
+
processor
|
|
8945
|
+
);
|
|
8946
|
+
client.writer.write({
|
|
8947
|
+
type: "ENTRY_PROCESS_RESPONSE",
|
|
8948
|
+
requestId,
|
|
8949
|
+
success: result.success,
|
|
8950
|
+
result: result.result,
|
|
8951
|
+
newValue: result.newValue,
|
|
8952
|
+
error: result.error
|
|
8953
|
+
});
|
|
8954
|
+
if (result.success && timestamp) {
|
|
8955
|
+
const record = entryMap.getRecord(key);
|
|
8956
|
+
if (record) {
|
|
8957
|
+
this.queryRegistry.processChange(mapName, entryMap, key, record, void 0);
|
|
8958
|
+
}
|
|
8959
|
+
}
|
|
8960
|
+
logger.debug({
|
|
8961
|
+
clientId: client.id,
|
|
8962
|
+
mapName,
|
|
8963
|
+
key,
|
|
8964
|
+
processor: processor.name,
|
|
8965
|
+
success: result.success
|
|
8966
|
+
}, "Entry processor executed");
|
|
8967
|
+
break;
|
|
8968
|
+
}
|
|
8969
|
+
case "ENTRY_PROCESS_BATCH": {
|
|
8970
|
+
const { requestId, mapName, keys, processor } = message;
|
|
8971
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
8972
|
+
const errorResults = {};
|
|
8973
|
+
for (const key of keys) {
|
|
8974
|
+
errorResults[key] = {
|
|
8975
|
+
success: false,
|
|
8976
|
+
error: `Access Denied for map ${mapName}`
|
|
8977
|
+
};
|
|
8978
|
+
}
|
|
8979
|
+
client.writer.write({
|
|
8980
|
+
type: "ENTRY_PROCESS_BATCH_RESPONSE",
|
|
8981
|
+
requestId,
|
|
8982
|
+
results: errorResults
|
|
8983
|
+
}, true);
|
|
8984
|
+
break;
|
|
8985
|
+
}
|
|
8986
|
+
const batchMap = this.getMap(mapName);
|
|
8987
|
+
const { results, timestamps } = await this.entryProcessorHandler.executeOnKeys(
|
|
8988
|
+
batchMap,
|
|
8989
|
+
keys,
|
|
8990
|
+
processor
|
|
8991
|
+
);
|
|
8992
|
+
const resultsRecord = {};
|
|
8993
|
+
for (const [key, keyResult] of results) {
|
|
8994
|
+
resultsRecord[key] = {
|
|
8995
|
+
success: keyResult.success,
|
|
8996
|
+
result: keyResult.result,
|
|
8997
|
+
newValue: keyResult.newValue,
|
|
8998
|
+
error: keyResult.error
|
|
8999
|
+
};
|
|
9000
|
+
}
|
|
9001
|
+
client.writer.write({
|
|
9002
|
+
type: "ENTRY_PROCESS_BATCH_RESPONSE",
|
|
9003
|
+
requestId,
|
|
9004
|
+
results: resultsRecord
|
|
9005
|
+
});
|
|
9006
|
+
for (const [key] of timestamps) {
|
|
9007
|
+
const record = batchMap.getRecord(key);
|
|
9008
|
+
if (record) {
|
|
9009
|
+
this.queryRegistry.processChange(mapName, batchMap, key, record, void 0);
|
|
9010
|
+
}
|
|
9011
|
+
}
|
|
9012
|
+
logger.debug({
|
|
9013
|
+
clientId: client.id,
|
|
9014
|
+
mapName,
|
|
9015
|
+
keyCount: keys.length,
|
|
9016
|
+
processor: processor.name,
|
|
9017
|
+
successCount: Array.from(results.values()).filter((r) => r.success).length
|
|
9018
|
+
}, "Entry processor batch executed");
|
|
9019
|
+
break;
|
|
9020
|
+
}
|
|
9021
|
+
// ============ Phase 5.05: Conflict Resolver Handlers ============
|
|
9022
|
+
case "REGISTER_RESOLVER": {
|
|
9023
|
+
const { requestId, mapName, resolver } = message;
|
|
9024
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
9025
|
+
client.writer.write({
|
|
9026
|
+
type: "REGISTER_RESOLVER_RESPONSE",
|
|
9027
|
+
requestId,
|
|
9028
|
+
success: false,
|
|
9029
|
+
error: `Access Denied for map ${mapName}`
|
|
9030
|
+
}, true);
|
|
9031
|
+
break;
|
|
9032
|
+
}
|
|
9033
|
+
try {
|
|
9034
|
+
this.conflictResolverHandler.registerResolver(
|
|
9035
|
+
mapName,
|
|
9036
|
+
{
|
|
9037
|
+
name: resolver.name,
|
|
9038
|
+
code: resolver.code,
|
|
9039
|
+
priority: resolver.priority,
|
|
9040
|
+
keyPattern: resolver.keyPattern
|
|
9041
|
+
},
|
|
9042
|
+
client.id
|
|
9043
|
+
);
|
|
9044
|
+
client.writer.write({
|
|
9045
|
+
type: "REGISTER_RESOLVER_RESPONSE",
|
|
9046
|
+
requestId,
|
|
9047
|
+
success: true
|
|
9048
|
+
});
|
|
9049
|
+
logger.info({
|
|
9050
|
+
clientId: client.id,
|
|
9051
|
+
mapName,
|
|
9052
|
+
resolverName: resolver.name,
|
|
9053
|
+
priority: resolver.priority
|
|
9054
|
+
}, "Conflict resolver registered");
|
|
9055
|
+
} catch (err) {
|
|
9056
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
9057
|
+
client.writer.write({
|
|
9058
|
+
type: "REGISTER_RESOLVER_RESPONSE",
|
|
9059
|
+
requestId,
|
|
9060
|
+
success: false,
|
|
9061
|
+
error: errorMessage
|
|
9062
|
+
}, true);
|
|
9063
|
+
logger.warn({
|
|
9064
|
+
clientId: client.id,
|
|
9065
|
+
mapName,
|
|
9066
|
+
error: errorMessage
|
|
9067
|
+
}, "Failed to register conflict resolver");
|
|
9068
|
+
}
|
|
9069
|
+
break;
|
|
9070
|
+
}
|
|
9071
|
+
case "UNREGISTER_RESOLVER": {
|
|
9072
|
+
const { requestId, mapName, resolverName } = message;
|
|
9073
|
+
if (!this.securityManager.checkPermission(client.principal, mapName, "PUT")) {
|
|
9074
|
+
client.writer.write({
|
|
9075
|
+
type: "UNREGISTER_RESOLVER_RESPONSE",
|
|
9076
|
+
requestId,
|
|
9077
|
+
success: false,
|
|
9078
|
+
error: `Access Denied for map ${mapName}`
|
|
9079
|
+
}, true);
|
|
9080
|
+
break;
|
|
9081
|
+
}
|
|
9082
|
+
const removed = this.conflictResolverHandler.unregisterResolver(
|
|
9083
|
+
mapName,
|
|
9084
|
+
resolverName,
|
|
9085
|
+
client.id
|
|
9086
|
+
);
|
|
9087
|
+
client.writer.write({
|
|
9088
|
+
type: "UNREGISTER_RESOLVER_RESPONSE",
|
|
9089
|
+
requestId,
|
|
9090
|
+
success: removed,
|
|
9091
|
+
error: removed ? void 0 : "Resolver not found or not owned by this client"
|
|
9092
|
+
});
|
|
9093
|
+
if (removed) {
|
|
9094
|
+
logger.info({
|
|
9095
|
+
clientId: client.id,
|
|
9096
|
+
mapName,
|
|
9097
|
+
resolverName
|
|
9098
|
+
}, "Conflict resolver unregistered");
|
|
9099
|
+
}
|
|
9100
|
+
break;
|
|
9101
|
+
}
|
|
9102
|
+
case "LIST_RESOLVERS": {
|
|
9103
|
+
const { requestId, mapName } = message;
|
|
9104
|
+
if (mapName && !this.securityManager.checkPermission(client.principal, mapName, "READ")) {
|
|
9105
|
+
client.writer.write({
|
|
9106
|
+
type: "LIST_RESOLVERS_RESPONSE",
|
|
9107
|
+
requestId,
|
|
9108
|
+
resolvers: []
|
|
9109
|
+
});
|
|
9110
|
+
break;
|
|
9111
|
+
}
|
|
9112
|
+
const resolvers = this.conflictResolverHandler.listResolvers(mapName);
|
|
9113
|
+
client.writer.write({
|
|
9114
|
+
type: "LIST_RESOLVERS_RESPONSE",
|
|
9115
|
+
requestId,
|
|
9116
|
+
resolvers
|
|
9117
|
+
});
|
|
9118
|
+
break;
|
|
9119
|
+
}
|
|
7346
9120
|
// ============ Phase 4: Partition Map Request Handler ============
|
|
7347
9121
|
case "PARTITION_MAP_REQUEST": {
|
|
7348
9122
|
const clientVersion = message.payload?.currentVersion ?? 0;
|
|
@@ -7383,7 +9157,7 @@ var ServerCoordinator = class {
|
|
|
7383
9157
|
this.metricsService.incOp("GET", message.mapName);
|
|
7384
9158
|
try {
|
|
7385
9159
|
const mapForSync = await this.getMapAsync(message.mapName, "OR");
|
|
7386
|
-
if (mapForSync instanceof
|
|
9160
|
+
if (mapForSync instanceof import_core15.ORMap) {
|
|
7387
9161
|
const tree = mapForSync.getMerkleTree();
|
|
7388
9162
|
const rootHash = tree.getRootHash();
|
|
7389
9163
|
client.writer.write({
|
|
@@ -7420,7 +9194,7 @@ var ServerCoordinator = class {
|
|
|
7420
9194
|
const { mapName, path } = message.payload;
|
|
7421
9195
|
try {
|
|
7422
9196
|
const mapForBucket = await this.getMapAsync(mapName, "OR");
|
|
7423
|
-
if (mapForBucket instanceof
|
|
9197
|
+
if (mapForBucket instanceof import_core15.ORMap) {
|
|
7424
9198
|
const tree = mapForBucket.getMerkleTree();
|
|
7425
9199
|
const buckets = tree.getBuckets(path);
|
|
7426
9200
|
const isLeaf = tree.isLeaf(path);
|
|
@@ -7464,7 +9238,7 @@ var ServerCoordinator = class {
|
|
|
7464
9238
|
const { mapName: diffMapName, keys } = message.payload;
|
|
7465
9239
|
try {
|
|
7466
9240
|
const mapForDiff = await this.getMapAsync(diffMapName, "OR");
|
|
7467
|
-
if (mapForDiff instanceof
|
|
9241
|
+
if (mapForDiff instanceof import_core15.ORMap) {
|
|
7468
9242
|
const entries = [];
|
|
7469
9243
|
const allTombstones = mapForDiff.getTombstones();
|
|
7470
9244
|
for (const key of keys) {
|
|
@@ -7496,7 +9270,7 @@ var ServerCoordinator = class {
|
|
|
7496
9270
|
const { mapName: pushMapName, entries: pushEntries } = message.payload;
|
|
7497
9271
|
try {
|
|
7498
9272
|
const mapForPush = await this.getMapAsync(pushMapName, "OR");
|
|
7499
|
-
if (mapForPush instanceof
|
|
9273
|
+
if (mapForPush instanceof import_core15.ORMap) {
|
|
7500
9274
|
let totalAdded = 0;
|
|
7501
9275
|
let totalUpdated = 0;
|
|
7502
9276
|
for (const entry of pushEntries) {
|
|
@@ -7538,6 +9312,92 @@ var ServerCoordinator = class {
|
|
|
7538
9312
|
}
|
|
7539
9313
|
break;
|
|
7540
9314
|
}
|
|
9315
|
+
// === Event Journal Messages (Phase 5.04) ===
|
|
9316
|
+
case "JOURNAL_SUBSCRIBE": {
|
|
9317
|
+
if (!this.eventJournalService) {
|
|
9318
|
+
client.writer.write({
|
|
9319
|
+
type: "ERROR",
|
|
9320
|
+
payload: { code: 503, message: "Event journal not enabled" }
|
|
9321
|
+
}, true);
|
|
9322
|
+
break;
|
|
9323
|
+
}
|
|
9324
|
+
const { requestId, fromSequence, mapName, types } = message;
|
|
9325
|
+
const subscriptionId = requestId;
|
|
9326
|
+
this.journalSubscriptions.set(subscriptionId, {
|
|
9327
|
+
clientId: client.id,
|
|
9328
|
+
mapName,
|
|
9329
|
+
types
|
|
9330
|
+
});
|
|
9331
|
+
const unsubscribe = this.eventJournalService.subscribe(
|
|
9332
|
+
(event) => {
|
|
9333
|
+
if (mapName && event.mapName !== mapName) return;
|
|
9334
|
+
if (types && types.length > 0 && !types.includes(event.type)) return;
|
|
9335
|
+
const clientConn = this.clients.get(client.id);
|
|
9336
|
+
if (!clientConn) {
|
|
9337
|
+
unsubscribe();
|
|
9338
|
+
this.journalSubscriptions.delete(subscriptionId);
|
|
9339
|
+
return;
|
|
9340
|
+
}
|
|
9341
|
+
clientConn.writer.write({
|
|
9342
|
+
type: "JOURNAL_EVENT",
|
|
9343
|
+
event: {
|
|
9344
|
+
sequence: event.sequence.toString(),
|
|
9345
|
+
type: event.type,
|
|
9346
|
+
mapName: event.mapName,
|
|
9347
|
+
key: event.key,
|
|
9348
|
+
value: event.value,
|
|
9349
|
+
previousValue: event.previousValue,
|
|
9350
|
+
timestamp: event.timestamp,
|
|
9351
|
+
nodeId: event.nodeId,
|
|
9352
|
+
metadata: event.metadata
|
|
9353
|
+
}
|
|
9354
|
+
});
|
|
9355
|
+
},
|
|
9356
|
+
fromSequence ? BigInt(fromSequence) : void 0
|
|
9357
|
+
);
|
|
9358
|
+
logger.info({ clientId: client.id, subscriptionId, mapName }, "Journal subscription created");
|
|
9359
|
+
break;
|
|
9360
|
+
}
|
|
9361
|
+
case "JOURNAL_UNSUBSCRIBE": {
|
|
9362
|
+
const { subscriptionId } = message;
|
|
9363
|
+
this.journalSubscriptions.delete(subscriptionId);
|
|
9364
|
+
logger.info({ clientId: client.id, subscriptionId }, "Journal subscription removed");
|
|
9365
|
+
break;
|
|
9366
|
+
}
|
|
9367
|
+
case "JOURNAL_READ": {
|
|
9368
|
+
if (!this.eventJournalService) {
|
|
9369
|
+
client.writer.write({
|
|
9370
|
+
type: "ERROR",
|
|
9371
|
+
payload: { code: 503, message: "Event journal not enabled" }
|
|
9372
|
+
}, true);
|
|
9373
|
+
break;
|
|
9374
|
+
}
|
|
9375
|
+
const { requestId: readReqId, fromSequence: readFromSeq, limit, mapName: readMapName } = message;
|
|
9376
|
+
const startSeq = BigInt(readFromSeq);
|
|
9377
|
+
const eventLimit = limit ?? 100;
|
|
9378
|
+
let events = this.eventJournalService.readFrom(startSeq, eventLimit);
|
|
9379
|
+
if (readMapName) {
|
|
9380
|
+
events = events.filter((e) => e.mapName === readMapName);
|
|
9381
|
+
}
|
|
9382
|
+
const serializedEvents = events.map((e) => ({
|
|
9383
|
+
sequence: e.sequence.toString(),
|
|
9384
|
+
type: e.type,
|
|
9385
|
+
mapName: e.mapName,
|
|
9386
|
+
key: e.key,
|
|
9387
|
+
value: e.value,
|
|
9388
|
+
previousValue: e.previousValue,
|
|
9389
|
+
timestamp: e.timestamp,
|
|
9390
|
+
nodeId: e.nodeId,
|
|
9391
|
+
metadata: e.metadata
|
|
9392
|
+
}));
|
|
9393
|
+
client.writer.write({
|
|
9394
|
+
type: "JOURNAL_READ_RESPONSE",
|
|
9395
|
+
requestId: readReqId,
|
|
9396
|
+
events: serializedEvents,
|
|
9397
|
+
hasMore: events.length === eventLimit
|
|
9398
|
+
});
|
|
9399
|
+
break;
|
|
9400
|
+
}
|
|
7541
9401
|
default:
|
|
7542
9402
|
logger.warn({ type: message.type }, "Unknown message type");
|
|
7543
9403
|
}
|
|
@@ -7551,7 +9411,7 @@ var ServerCoordinator = class {
|
|
|
7551
9411
|
} else if (op.orRecord && op.orRecord.timestamp) {
|
|
7552
9412
|
} else if (op.orTag) {
|
|
7553
9413
|
try {
|
|
7554
|
-
ts =
|
|
9414
|
+
ts = import_core15.HLC.parse(op.orTag);
|
|
7555
9415
|
} catch (e) {
|
|
7556
9416
|
}
|
|
7557
9417
|
}
|
|
@@ -7585,6 +9445,39 @@ var ServerCoordinator = class {
|
|
|
7585
9445
|
clientCount: broadcastCount
|
|
7586
9446
|
}, "Broadcast partition map to clients");
|
|
7587
9447
|
}
|
|
9448
|
+
/**
|
|
9449
|
+
* Notify a client about a merge rejection (Phase 5.05).
|
|
9450
|
+
* Finds the client by node ID and sends MERGE_REJECTED message.
|
|
9451
|
+
*/
|
|
9452
|
+
notifyMergeRejection(rejection) {
|
|
9453
|
+
for (const [clientId, client] of this.clients) {
|
|
9454
|
+
if (clientId === rejection.nodeId || rejection.nodeId.includes(clientId)) {
|
|
9455
|
+
client.writer.write({
|
|
9456
|
+
type: "MERGE_REJECTED",
|
|
9457
|
+
mapName: rejection.mapName,
|
|
9458
|
+
key: rejection.key,
|
|
9459
|
+
attemptedValue: rejection.attemptedValue,
|
|
9460
|
+
reason: rejection.reason,
|
|
9461
|
+
timestamp: rejection.timestamp
|
|
9462
|
+
}, true);
|
|
9463
|
+
return;
|
|
9464
|
+
}
|
|
9465
|
+
}
|
|
9466
|
+
const subscribedClientIds = this.queryRegistry.getSubscribedClientIds(rejection.mapName);
|
|
9467
|
+
for (const clientId of subscribedClientIds) {
|
|
9468
|
+
const client = this.clients.get(clientId);
|
|
9469
|
+
if (client) {
|
|
9470
|
+
client.writer.write({
|
|
9471
|
+
type: "MERGE_REJECTED",
|
|
9472
|
+
mapName: rejection.mapName,
|
|
9473
|
+
key: rejection.key,
|
|
9474
|
+
attemptedValue: rejection.attemptedValue,
|
|
9475
|
+
reason: rejection.reason,
|
|
9476
|
+
timestamp: rejection.timestamp
|
|
9477
|
+
});
|
|
9478
|
+
}
|
|
9479
|
+
}
|
|
9480
|
+
}
|
|
7588
9481
|
broadcast(message, excludeClientId) {
|
|
7589
9482
|
const isServerEvent = message.type === "SERVER_EVENT";
|
|
7590
9483
|
if (isServerEvent) {
|
|
@@ -7615,7 +9508,7 @@ var ServerCoordinator = class {
|
|
|
7615
9508
|
client.writer.write({ ...message, payload: newPayload });
|
|
7616
9509
|
}
|
|
7617
9510
|
} else {
|
|
7618
|
-
const msgData = (0,
|
|
9511
|
+
const msgData = (0, import_core15.serialize)(message);
|
|
7619
9512
|
for (const [id, client] of this.clients) {
|
|
7620
9513
|
if (id !== excludeClientId && client.socket.readyState === 1) {
|
|
7621
9514
|
client.writer.writeRaw(msgData);
|
|
@@ -7693,7 +9586,7 @@ var ServerCoordinator = class {
|
|
|
7693
9586
|
payload: { events: filteredEvents },
|
|
7694
9587
|
timestamp: this.hlc.now()
|
|
7695
9588
|
};
|
|
7696
|
-
const serializedBatch = (0,
|
|
9589
|
+
const serializedBatch = (0, import_core15.serialize)(batchMessage);
|
|
7697
9590
|
for (const client of clients) {
|
|
7698
9591
|
try {
|
|
7699
9592
|
client.writer.writeRaw(serializedBatch);
|
|
@@ -7778,7 +9671,7 @@ var ServerCoordinator = class {
|
|
|
7778
9671
|
payload: { events: filteredEvents },
|
|
7779
9672
|
timestamp: this.hlc.now()
|
|
7780
9673
|
};
|
|
7781
|
-
const serializedBatch = (0,
|
|
9674
|
+
const serializedBatch = (0, import_core15.serialize)(batchMessage);
|
|
7782
9675
|
for (const client of clients) {
|
|
7783
9676
|
sendPromises.push(new Promise((resolve, reject) => {
|
|
7784
9677
|
try {
|
|
@@ -7921,15 +9814,35 @@ var ServerCoordinator = class {
|
|
|
7921
9814
|
}
|
|
7922
9815
|
async executeLocalQuery(mapName, query) {
|
|
7923
9816
|
const map = await this.getMapAsync(mapName);
|
|
9817
|
+
const localQuery = { ...query };
|
|
9818
|
+
delete localQuery.offset;
|
|
9819
|
+
delete localQuery.limit;
|
|
9820
|
+
if (map instanceof import_core15.IndexedLWWMap) {
|
|
9821
|
+
const coreQuery = this.convertToCoreQuery(localQuery);
|
|
9822
|
+
if (coreQuery) {
|
|
9823
|
+
const entries = map.queryEntries(coreQuery);
|
|
9824
|
+
return entries.map(([key, value]) => {
|
|
9825
|
+
const record = map.getRecord(key);
|
|
9826
|
+
return { key, value, timestamp: record?.timestamp };
|
|
9827
|
+
});
|
|
9828
|
+
}
|
|
9829
|
+
}
|
|
9830
|
+
if (map instanceof import_core15.IndexedORMap) {
|
|
9831
|
+
const coreQuery = this.convertToCoreQuery(localQuery);
|
|
9832
|
+
if (coreQuery) {
|
|
9833
|
+
const results = map.query(coreQuery);
|
|
9834
|
+
return results.map(({ key, value }) => ({ key, value }));
|
|
9835
|
+
}
|
|
9836
|
+
}
|
|
7924
9837
|
const records = /* @__PURE__ */ new Map();
|
|
7925
|
-
if (map instanceof
|
|
9838
|
+
if (map instanceof import_core15.LWWMap) {
|
|
7926
9839
|
for (const key of map.allKeys()) {
|
|
7927
9840
|
const rec = map.getRecord(key);
|
|
7928
9841
|
if (rec && rec.value !== null) {
|
|
7929
9842
|
records.set(key, rec);
|
|
7930
9843
|
}
|
|
7931
9844
|
}
|
|
7932
|
-
} else if (map instanceof
|
|
9845
|
+
} else if (map instanceof import_core15.ORMap) {
|
|
7933
9846
|
const items = map.items;
|
|
7934
9847
|
for (const key of items.keys()) {
|
|
7935
9848
|
const values = map.get(key);
|
|
@@ -7938,11 +9851,89 @@ var ServerCoordinator = class {
|
|
|
7938
9851
|
}
|
|
7939
9852
|
}
|
|
7940
9853
|
}
|
|
7941
|
-
const localQuery = { ...query };
|
|
7942
|
-
delete localQuery.offset;
|
|
7943
|
-
delete localQuery.limit;
|
|
7944
9854
|
return executeQuery(records, localQuery);
|
|
7945
9855
|
}
|
|
9856
|
+
/**
|
|
9857
|
+
* Convert server Query format to core Query format for indexed execution.
|
|
9858
|
+
* Returns null if conversion is not possible (complex queries).
|
|
9859
|
+
*/
|
|
9860
|
+
convertToCoreQuery(query) {
|
|
9861
|
+
if (query.predicate) {
|
|
9862
|
+
return this.predicateToCoreQuery(query.predicate);
|
|
9863
|
+
}
|
|
9864
|
+
if (query.where) {
|
|
9865
|
+
const conditions = [];
|
|
9866
|
+
for (const [attribute, condition] of Object.entries(query.where)) {
|
|
9867
|
+
if (typeof condition !== "object" || condition === null) {
|
|
9868
|
+
conditions.push({ type: "eq", attribute, value: condition });
|
|
9869
|
+
} else {
|
|
9870
|
+
for (const [op, value] of Object.entries(condition)) {
|
|
9871
|
+
const coreOp = this.convertOperator(op);
|
|
9872
|
+
if (coreOp) {
|
|
9873
|
+
conditions.push({ type: coreOp, attribute, value });
|
|
9874
|
+
}
|
|
9875
|
+
}
|
|
9876
|
+
}
|
|
9877
|
+
}
|
|
9878
|
+
if (conditions.length === 0) return null;
|
|
9879
|
+
if (conditions.length === 1) return conditions[0];
|
|
9880
|
+
return { type: "and", children: conditions };
|
|
9881
|
+
}
|
|
9882
|
+
return null;
|
|
9883
|
+
}
|
|
9884
|
+
/**
|
|
9885
|
+
* Convert predicate node to core Query format.
|
|
9886
|
+
*/
|
|
9887
|
+
predicateToCoreQuery(predicate) {
|
|
9888
|
+
if (!predicate || !predicate.op) return null;
|
|
9889
|
+
switch (predicate.op) {
|
|
9890
|
+
case "eq":
|
|
9891
|
+
case "neq":
|
|
9892
|
+
case "gt":
|
|
9893
|
+
case "gte":
|
|
9894
|
+
case "lt":
|
|
9895
|
+
case "lte":
|
|
9896
|
+
return {
|
|
9897
|
+
type: predicate.op,
|
|
9898
|
+
attribute: predicate.attribute,
|
|
9899
|
+
value: predicate.value
|
|
9900
|
+
};
|
|
9901
|
+
case "and":
|
|
9902
|
+
case "or":
|
|
9903
|
+
if (predicate.children && Array.isArray(predicate.children)) {
|
|
9904
|
+
const children = predicate.children.map((c) => this.predicateToCoreQuery(c)).filter((c) => c !== null);
|
|
9905
|
+
if (children.length === 0) return null;
|
|
9906
|
+
if (children.length === 1) return children[0];
|
|
9907
|
+
return { type: predicate.op, children };
|
|
9908
|
+
}
|
|
9909
|
+
return null;
|
|
9910
|
+
case "not":
|
|
9911
|
+
if (predicate.children && predicate.children[0]) {
|
|
9912
|
+
const child = this.predicateToCoreQuery(predicate.children[0]);
|
|
9913
|
+
if (child) {
|
|
9914
|
+
return { type: "not", child };
|
|
9915
|
+
}
|
|
9916
|
+
}
|
|
9917
|
+
return null;
|
|
9918
|
+
default:
|
|
9919
|
+
return null;
|
|
9920
|
+
}
|
|
9921
|
+
}
|
|
9922
|
+
/**
|
|
9923
|
+
* Convert server operator to core query type.
|
|
9924
|
+
*/
|
|
9925
|
+
convertOperator(op) {
|
|
9926
|
+
const mapping = {
|
|
9927
|
+
"$eq": "eq",
|
|
9928
|
+
"$ne": "neq",
|
|
9929
|
+
"$neq": "neq",
|
|
9930
|
+
"$gt": "gt",
|
|
9931
|
+
"$gte": "gte",
|
|
9932
|
+
"$lt": "lt",
|
|
9933
|
+
"$lte": "lte"
|
|
9934
|
+
};
|
|
9935
|
+
return mapping[op] || null;
|
|
9936
|
+
}
|
|
7946
9937
|
finalizeClusterQuery(requestId, timeout = false) {
|
|
7947
9938
|
const pending = this.pendingClusterQueries.get(requestId);
|
|
7948
9939
|
if (!pending) return;
|
|
@@ -7996,14 +9987,14 @@ var ServerCoordinator = class {
|
|
|
7996
9987
|
*
|
|
7997
9988
|
* @returns Event payload for broadcasting (or null if operation failed)
|
|
7998
9989
|
*/
|
|
7999
|
-
applyOpToMap(op) {
|
|
9990
|
+
async applyOpToMap(op, remoteNodeId) {
|
|
8000
9991
|
const typeHint = op.opType === "OR_ADD" || op.opType === "OR_REMOVE" ? "OR" : "LWW";
|
|
8001
9992
|
const map = this.getMap(op.mapName, typeHint);
|
|
8002
|
-
if (typeHint === "OR" && map instanceof
|
|
9993
|
+
if (typeHint === "OR" && map instanceof import_core15.LWWMap) {
|
|
8003
9994
|
logger.error({ mapName: op.mapName }, "Map type mismatch: LWWMap but received OR op");
|
|
8004
9995
|
throw new Error("Map type mismatch: LWWMap but received OR op");
|
|
8005
9996
|
}
|
|
8006
|
-
if (typeHint === "LWW" && map instanceof
|
|
9997
|
+
if (typeHint === "LWW" && map instanceof import_core15.ORMap) {
|
|
8007
9998
|
logger.error({ mapName: op.mapName }, "Map type mismatch: ORMap but received LWW op");
|
|
8008
9999
|
throw new Error("Map type mismatch: ORMap but received LWW op");
|
|
8009
10000
|
}
|
|
@@ -8014,13 +10005,35 @@ var ServerCoordinator = class {
|
|
|
8014
10005
|
mapName: op.mapName,
|
|
8015
10006
|
key: op.key
|
|
8016
10007
|
};
|
|
8017
|
-
if (map instanceof
|
|
10008
|
+
if (map instanceof import_core15.LWWMap) {
|
|
8018
10009
|
oldRecord = map.getRecord(op.key);
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
10010
|
+
if (this.conflictResolverHandler.hasResolvers(op.mapName)) {
|
|
10011
|
+
const mergeResult = await this.conflictResolverHandler.mergeWithResolver(
|
|
10012
|
+
map,
|
|
10013
|
+
op.mapName,
|
|
10014
|
+
op.key,
|
|
10015
|
+
op.record,
|
|
10016
|
+
remoteNodeId || this._nodeId
|
|
10017
|
+
);
|
|
10018
|
+
if (!mergeResult.applied) {
|
|
10019
|
+
if (mergeResult.rejection) {
|
|
10020
|
+
logger.debug(
|
|
10021
|
+
{ mapName: op.mapName, key: op.key, reason: mergeResult.rejection.reason },
|
|
10022
|
+
"Merge rejected by resolver"
|
|
10023
|
+
);
|
|
10024
|
+
}
|
|
10025
|
+
return { eventPayload: null, oldRecord, rejected: true };
|
|
10026
|
+
}
|
|
10027
|
+
recordToStore = mergeResult.record;
|
|
10028
|
+
eventPayload.eventType = "UPDATED";
|
|
10029
|
+
eventPayload.record = mergeResult.record;
|
|
10030
|
+
} else {
|
|
10031
|
+
map.merge(op.key, op.record);
|
|
10032
|
+
recordToStore = op.record;
|
|
10033
|
+
eventPayload.eventType = "UPDATED";
|
|
10034
|
+
eventPayload.record = op.record;
|
|
10035
|
+
}
|
|
10036
|
+
} else if (map instanceof import_core15.ORMap) {
|
|
8024
10037
|
oldRecord = map.getRecords(op.key);
|
|
8025
10038
|
if (op.opType === "OR_ADD") {
|
|
8026
10039
|
map.apply(op.key, op.orRecord);
|
|
@@ -8036,7 +10049,7 @@ var ServerCoordinator = class {
|
|
|
8036
10049
|
}
|
|
8037
10050
|
}
|
|
8038
10051
|
this.queryRegistry.processChange(op.mapName, map, op.key, op.record || op.orRecord, oldRecord);
|
|
8039
|
-
const mapSize = map instanceof
|
|
10052
|
+
const mapSize = map instanceof import_core15.ORMap ? map.totalRecords : map.size;
|
|
8040
10053
|
this.metricsService.setMapSize(op.mapName, mapSize);
|
|
8041
10054
|
if (this.storage) {
|
|
8042
10055
|
if (recordToStore) {
|
|
@@ -8050,6 +10063,21 @@ var ServerCoordinator = class {
|
|
|
8050
10063
|
});
|
|
8051
10064
|
}
|
|
8052
10065
|
}
|
|
10066
|
+
if (this.eventJournalService) {
|
|
10067
|
+
const isDelete = op.opType === "REMOVE" || op.opType === "OR_REMOVE" || op.record && op.record.value === null;
|
|
10068
|
+
const isNew = !oldRecord || Array.isArray(oldRecord) && oldRecord.length === 0;
|
|
10069
|
+
const journalEventType = isDelete ? "DELETE" : isNew ? "PUT" : "UPDATE";
|
|
10070
|
+
const timestamp = op.record?.timestamp || op.orRecord?.timestamp || this.hlc.now();
|
|
10071
|
+
this.eventJournalService.append({
|
|
10072
|
+
type: journalEventType,
|
|
10073
|
+
mapName: op.mapName,
|
|
10074
|
+
key: op.key,
|
|
10075
|
+
value: op.record?.value ?? op.orRecord?.value,
|
|
10076
|
+
previousValue: oldRecord?.value ?? (Array.isArray(oldRecord) ? oldRecord[0]?.value : void 0),
|
|
10077
|
+
timestamp,
|
|
10078
|
+
nodeId: this._nodeId
|
|
10079
|
+
});
|
|
10080
|
+
}
|
|
8053
10081
|
return { eventPayload, oldRecord };
|
|
8054
10082
|
}
|
|
8055
10083
|
/**
|
|
@@ -8071,7 +10099,10 @@ var ServerCoordinator = class {
|
|
|
8071
10099
|
try {
|
|
8072
10100
|
const op = operation;
|
|
8073
10101
|
logger.debug({ sourceNode, opId, mapName: op.mapName, key: op.key }, "Applying replicated operation");
|
|
8074
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
10102
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, sourceNode);
|
|
10103
|
+
if (rejected || !eventPayload) {
|
|
10104
|
+
return true;
|
|
10105
|
+
}
|
|
8075
10106
|
this.broadcast({
|
|
8076
10107
|
type: "SERVER_EVENT",
|
|
8077
10108
|
payload: eventPayload,
|
|
@@ -8170,7 +10201,10 @@ var ServerCoordinator = class {
|
|
|
8170
10201
|
logger.warn({ err, opId: op.id }, "Interceptor rejected op");
|
|
8171
10202
|
throw err;
|
|
8172
10203
|
}
|
|
8173
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
10204
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, originalSenderId);
|
|
10205
|
+
if (rejected || !eventPayload) {
|
|
10206
|
+
return;
|
|
10207
|
+
}
|
|
8174
10208
|
if (this.replicationPipeline && !fromCluster) {
|
|
8175
10209
|
const opId = op.id || `${op.mapName}:${op.key}:${Date.now()}`;
|
|
8176
10210
|
this.replicationPipeline.replicate(op, opId, op.key).catch((err) => {
|
|
@@ -8298,7 +10332,10 @@ var ServerCoordinator = class {
|
|
|
8298
10332
|
logger.warn({ err, opId: op.id }, "Interceptor rejected op in batch");
|
|
8299
10333
|
throw err;
|
|
8300
10334
|
}
|
|
8301
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
10335
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, clientId);
|
|
10336
|
+
if (rejected || !eventPayload) {
|
|
10337
|
+
return;
|
|
10338
|
+
}
|
|
8302
10339
|
if (this.replicationPipeline) {
|
|
8303
10340
|
const opId = op.id || `${op.mapName}:${op.key}:${Date.now()}`;
|
|
8304
10341
|
this.replicationPipeline.replicate(op, opId, op.key).catch((err) => {
|
|
@@ -8312,11 +10349,11 @@ var ServerCoordinator = class {
|
|
|
8312
10349
|
handleClusterEvent(payload) {
|
|
8313
10350
|
const { mapName, key, eventType } = payload;
|
|
8314
10351
|
const map = this.getMap(mapName, eventType === "OR_ADD" || eventType === "OR_REMOVE" ? "OR" : "LWW");
|
|
8315
|
-
const oldRecord = map instanceof
|
|
10352
|
+
const oldRecord = map instanceof import_core15.LWWMap ? map.getRecord(key) : null;
|
|
8316
10353
|
if (this.partitionService.isRelated(key)) {
|
|
8317
|
-
if (map instanceof
|
|
10354
|
+
if (map instanceof import_core15.LWWMap && payload.record) {
|
|
8318
10355
|
map.merge(key, payload.record);
|
|
8319
|
-
} else if (map instanceof
|
|
10356
|
+
} else if (map instanceof import_core15.ORMap) {
|
|
8320
10357
|
if (eventType === "OR_ADD" && payload.orRecord) {
|
|
8321
10358
|
map.apply(key, payload.orRecord);
|
|
8322
10359
|
} else if (eventType === "OR_REMOVE" && payload.orTag) {
|
|
@@ -8335,9 +10372,9 @@ var ServerCoordinator = class {
|
|
|
8335
10372
|
if (!this.maps.has(name)) {
|
|
8336
10373
|
let map;
|
|
8337
10374
|
if (typeHint === "OR") {
|
|
8338
|
-
map = new
|
|
10375
|
+
map = new import_core15.ORMap(this.hlc);
|
|
8339
10376
|
} else {
|
|
8340
|
-
map = new
|
|
10377
|
+
map = new import_core15.LWWMap(this.hlc);
|
|
8341
10378
|
}
|
|
8342
10379
|
this.maps.set(name, map);
|
|
8343
10380
|
if (this.storage) {
|
|
@@ -8360,7 +10397,7 @@ var ServerCoordinator = class {
|
|
|
8360
10397
|
this.getMap(name, typeHint);
|
|
8361
10398
|
const loadingPromise = this.mapLoadingPromises.get(name);
|
|
8362
10399
|
const map = this.maps.get(name);
|
|
8363
|
-
const mapSize = map instanceof
|
|
10400
|
+
const mapSize = map instanceof import_core15.LWWMap ? Array.from(map.entries()).length : map instanceof import_core15.ORMap ? map.size : 0;
|
|
8364
10401
|
logger.info({
|
|
8365
10402
|
mapName: name,
|
|
8366
10403
|
mapExisted,
|
|
@@ -8370,7 +10407,7 @@ var ServerCoordinator = class {
|
|
|
8370
10407
|
if (loadingPromise) {
|
|
8371
10408
|
logger.info({ mapName: name }, "[getMapAsync] Waiting for loadMapFromStorage...");
|
|
8372
10409
|
await loadingPromise;
|
|
8373
|
-
const newMapSize = map instanceof
|
|
10410
|
+
const newMapSize = map instanceof import_core15.LWWMap ? Array.from(map.entries()).length : map instanceof import_core15.ORMap ? map.size : 0;
|
|
8374
10411
|
logger.info({ mapName: name, mapSizeAfterLoad: newMapSize }, "[getMapAsync] Load completed");
|
|
8375
10412
|
}
|
|
8376
10413
|
return this.maps.get(name);
|
|
@@ -8396,16 +10433,16 @@ var ServerCoordinator = class {
|
|
|
8396
10433
|
const currentMap = this.maps.get(name);
|
|
8397
10434
|
if (!currentMap) return;
|
|
8398
10435
|
let targetMap = currentMap;
|
|
8399
|
-
if (isOR && currentMap instanceof
|
|
10436
|
+
if (isOR && currentMap instanceof import_core15.LWWMap) {
|
|
8400
10437
|
logger.info({ mapName: name }, "Map auto-detected as ORMap. Switching type.");
|
|
8401
|
-
targetMap = new
|
|
10438
|
+
targetMap = new import_core15.ORMap(this.hlc);
|
|
8402
10439
|
this.maps.set(name, targetMap);
|
|
8403
|
-
} else if (!isOR && currentMap instanceof
|
|
10440
|
+
} else if (!isOR && currentMap instanceof import_core15.ORMap && typeHint !== "OR") {
|
|
8404
10441
|
logger.info({ mapName: name }, "Map auto-detected as LWWMap. Switching type.");
|
|
8405
|
-
targetMap = new
|
|
10442
|
+
targetMap = new import_core15.LWWMap(this.hlc);
|
|
8406
10443
|
this.maps.set(name, targetMap);
|
|
8407
10444
|
}
|
|
8408
|
-
if (targetMap instanceof
|
|
10445
|
+
if (targetMap instanceof import_core15.ORMap) {
|
|
8409
10446
|
for (const [key, record] of records) {
|
|
8410
10447
|
if (key === "__tombstones__") {
|
|
8411
10448
|
const t = record;
|
|
@@ -8418,7 +10455,7 @@ var ServerCoordinator = class {
|
|
|
8418
10455
|
}
|
|
8419
10456
|
}
|
|
8420
10457
|
}
|
|
8421
|
-
} else if (targetMap instanceof
|
|
10458
|
+
} else if (targetMap instanceof import_core15.LWWMap) {
|
|
8422
10459
|
for (const [key, record] of records) {
|
|
8423
10460
|
if (!record.type) {
|
|
8424
10461
|
targetMap.merge(key, record);
|
|
@@ -8429,7 +10466,7 @@ var ServerCoordinator = class {
|
|
|
8429
10466
|
if (count > 0) {
|
|
8430
10467
|
logger.info({ mapName: name, count }, "Loaded records for map");
|
|
8431
10468
|
this.queryRegistry.refreshSubscriptions(name, targetMap);
|
|
8432
|
-
const mapSize = targetMap instanceof
|
|
10469
|
+
const mapSize = targetMap instanceof import_core15.ORMap ? targetMap.totalRecords : targetMap.size;
|
|
8433
10470
|
this.metricsService.setMapSize(name, mapSize);
|
|
8434
10471
|
}
|
|
8435
10472
|
} catch (err) {
|
|
@@ -8511,7 +10548,7 @@ var ServerCoordinator = class {
|
|
|
8511
10548
|
reportLocalHlc() {
|
|
8512
10549
|
let minHlc = this.hlc.now();
|
|
8513
10550
|
for (const client of this.clients.values()) {
|
|
8514
|
-
if (
|
|
10551
|
+
if (import_core15.HLC.compare(client.lastActiveHlc, minHlc) < 0) {
|
|
8515
10552
|
minHlc = client.lastActiveHlc;
|
|
8516
10553
|
}
|
|
8517
10554
|
}
|
|
@@ -8532,7 +10569,7 @@ var ServerCoordinator = class {
|
|
|
8532
10569
|
let globalSafe = this.hlc.now();
|
|
8533
10570
|
let initialized = false;
|
|
8534
10571
|
for (const ts of this.gcReports.values()) {
|
|
8535
|
-
if (!initialized ||
|
|
10572
|
+
if (!initialized || import_core15.HLC.compare(ts, globalSafe) < 0) {
|
|
8536
10573
|
globalSafe = ts;
|
|
8537
10574
|
initialized = true;
|
|
8538
10575
|
}
|
|
@@ -8567,7 +10604,7 @@ var ServerCoordinator = class {
|
|
|
8567
10604
|
logger.info({ olderThanMillis: olderThan.millis }, "Performing Garbage Collection");
|
|
8568
10605
|
const now = Date.now();
|
|
8569
10606
|
for (const [name, map] of this.maps) {
|
|
8570
|
-
if (map instanceof
|
|
10607
|
+
if (map instanceof import_core15.LWWMap) {
|
|
8571
10608
|
for (const key of map.allKeys()) {
|
|
8572
10609
|
const record = map.getRecord(key);
|
|
8573
10610
|
if (record && record.value !== null && record.ttlMs) {
|
|
@@ -8619,7 +10656,7 @@ var ServerCoordinator = class {
|
|
|
8619
10656
|
});
|
|
8620
10657
|
}
|
|
8621
10658
|
}
|
|
8622
|
-
} else if (map instanceof
|
|
10659
|
+
} else if (map instanceof import_core15.ORMap) {
|
|
8623
10660
|
const items = map.items;
|
|
8624
10661
|
const tombstonesSet = map.tombstones;
|
|
8625
10662
|
const tagsToExpire = [];
|
|
@@ -8722,17 +10759,17 @@ var ServerCoordinator = class {
|
|
|
8722
10759
|
stringToWriteConcern(value) {
|
|
8723
10760
|
switch (value) {
|
|
8724
10761
|
case "FIRE_AND_FORGET":
|
|
8725
|
-
return
|
|
10762
|
+
return import_core15.WriteConcern.FIRE_AND_FORGET;
|
|
8726
10763
|
case "MEMORY":
|
|
8727
|
-
return
|
|
10764
|
+
return import_core15.WriteConcern.MEMORY;
|
|
8728
10765
|
case "APPLIED":
|
|
8729
|
-
return
|
|
10766
|
+
return import_core15.WriteConcern.APPLIED;
|
|
8730
10767
|
case "REPLICATED":
|
|
8731
|
-
return
|
|
10768
|
+
return import_core15.WriteConcern.REPLICATED;
|
|
8732
10769
|
case "PERSISTED":
|
|
8733
|
-
return
|
|
10770
|
+
return import_core15.WriteConcern.PERSISTED;
|
|
8734
10771
|
default:
|
|
8735
|
-
return
|
|
10772
|
+
return import_core15.WriteConcern.MEMORY;
|
|
8736
10773
|
}
|
|
8737
10774
|
}
|
|
8738
10775
|
/**
|
|
@@ -8789,7 +10826,7 @@ var ServerCoordinator = class {
|
|
|
8789
10826
|
}
|
|
8790
10827
|
});
|
|
8791
10828
|
if (op.id) {
|
|
8792
|
-
this.writeAckManager.notifyLevel(op.id,
|
|
10829
|
+
this.writeAckManager.notifyLevel(op.id, import_core15.WriteConcern.REPLICATED);
|
|
8793
10830
|
}
|
|
8794
10831
|
}
|
|
8795
10832
|
}
|
|
@@ -8797,7 +10834,7 @@ var ServerCoordinator = class {
|
|
|
8797
10834
|
this.broadcastBatch(batchedEvents, clientId);
|
|
8798
10835
|
for (const op of ops) {
|
|
8799
10836
|
if (op.id && this.partitionService.isLocalOwner(op.key)) {
|
|
8800
|
-
this.writeAckManager.notifyLevel(op.id,
|
|
10837
|
+
this.writeAckManager.notifyLevel(op.id, import_core15.WriteConcern.REPLICATED);
|
|
8801
10838
|
}
|
|
8802
10839
|
}
|
|
8803
10840
|
}
|
|
@@ -8825,7 +10862,7 @@ var ServerCoordinator = class {
|
|
|
8825
10862
|
const owner = this.partitionService.getOwner(op.key);
|
|
8826
10863
|
await this.forwardOpAndWait(op, owner);
|
|
8827
10864
|
if (op.id) {
|
|
8828
|
-
this.writeAckManager.notifyLevel(op.id,
|
|
10865
|
+
this.writeAckManager.notifyLevel(op.id, import_core15.WriteConcern.REPLICATED);
|
|
8829
10866
|
}
|
|
8830
10867
|
}
|
|
8831
10868
|
}
|
|
@@ -8833,7 +10870,7 @@ var ServerCoordinator = class {
|
|
|
8833
10870
|
await this.broadcastBatchSync(batchedEvents, clientId);
|
|
8834
10871
|
for (const op of ops) {
|
|
8835
10872
|
if (op.id && this.partitionService.isLocalOwner(op.key)) {
|
|
8836
|
-
this.writeAckManager.notifyLevel(op.id,
|
|
10873
|
+
this.writeAckManager.notifyLevel(op.id, import_core15.WriteConcern.REPLICATED);
|
|
8837
10874
|
}
|
|
8838
10875
|
}
|
|
8839
10876
|
}
|
|
@@ -8859,9 +10896,15 @@ var ServerCoordinator = class {
|
|
|
8859
10896
|
}
|
|
8860
10897
|
return;
|
|
8861
10898
|
}
|
|
8862
|
-
const { eventPayload } = this.applyOpToMap(op);
|
|
10899
|
+
const { eventPayload, rejected } = await this.applyOpToMap(op, clientId);
|
|
10900
|
+
if (rejected) {
|
|
10901
|
+
if (op.id) {
|
|
10902
|
+
this.writeAckManager.failPending(op.id, "Rejected by conflict resolver");
|
|
10903
|
+
}
|
|
10904
|
+
return;
|
|
10905
|
+
}
|
|
8863
10906
|
if (op.id) {
|
|
8864
|
-
this.writeAckManager.notifyLevel(op.id,
|
|
10907
|
+
this.writeAckManager.notifyLevel(op.id, import_core15.WriteConcern.APPLIED);
|
|
8865
10908
|
}
|
|
8866
10909
|
if (eventPayload) {
|
|
8867
10910
|
batchedEvents.push({
|
|
@@ -8875,7 +10918,7 @@ var ServerCoordinator = class {
|
|
|
8875
10918
|
try {
|
|
8876
10919
|
await this.persistOpSync(op);
|
|
8877
10920
|
if (op.id) {
|
|
8878
|
-
this.writeAckManager.notifyLevel(op.id,
|
|
10921
|
+
this.writeAckManager.notifyLevel(op.id, import_core15.WriteConcern.PERSISTED);
|
|
8879
10922
|
}
|
|
8880
10923
|
} catch (err) {
|
|
8881
10924
|
logger.error({ opId: op.id, err }, "Persistence failed");
|
|
@@ -8941,9 +10984,9 @@ var ServerCoordinator = class {
|
|
|
8941
10984
|
// src/storage/PostgresAdapter.ts
|
|
8942
10985
|
var import_pg = require("pg");
|
|
8943
10986
|
var DEFAULT_TABLE_NAME = "topgun_maps";
|
|
8944
|
-
var
|
|
8945
|
-
function
|
|
8946
|
-
if (!
|
|
10987
|
+
var TABLE_NAME_REGEX2 = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
10988
|
+
function validateTableName2(name) {
|
|
10989
|
+
if (!TABLE_NAME_REGEX2.test(name)) {
|
|
8947
10990
|
throw new Error(
|
|
8948
10991
|
`Invalid table name "${name}". Table name must start with a letter or underscore and contain only alphanumeric characters and underscores.`
|
|
8949
10992
|
);
|
|
@@ -8957,7 +11000,7 @@ var PostgresAdapter = class {
|
|
|
8957
11000
|
this.pool = new import_pg.Pool(configOrPool);
|
|
8958
11001
|
}
|
|
8959
11002
|
const tableName = options?.tableName ?? DEFAULT_TABLE_NAME;
|
|
8960
|
-
|
|
11003
|
+
validateTableName2(tableName);
|
|
8961
11004
|
this.tableName = tableName;
|
|
8962
11005
|
}
|
|
8963
11006
|
async initialize() {
|
|
@@ -9218,10 +11261,10 @@ var RateLimitInterceptor = class {
|
|
|
9218
11261
|
};
|
|
9219
11262
|
|
|
9220
11263
|
// src/utils/nativeStats.ts
|
|
9221
|
-
var
|
|
11264
|
+
var import_core16 = require("@topgunbuild/core");
|
|
9222
11265
|
function getNativeModuleStatus() {
|
|
9223
11266
|
return {
|
|
9224
|
-
nativeHash: (0,
|
|
11267
|
+
nativeHash: (0, import_core16.isUsingNativeHash)(),
|
|
9225
11268
|
sharedArrayBuffer: SharedMemoryManager.isAvailable()
|
|
9226
11269
|
};
|
|
9227
11270
|
}
|
|
@@ -9255,11 +11298,11 @@ function logNativeStatus() {
|
|
|
9255
11298
|
|
|
9256
11299
|
// src/cluster/ClusterCoordinator.ts
|
|
9257
11300
|
var import_events9 = require("events");
|
|
9258
|
-
var
|
|
11301
|
+
var import_core17 = require("@topgunbuild/core");
|
|
9259
11302
|
var DEFAULT_CLUSTER_COORDINATOR_CONFIG = {
|
|
9260
11303
|
gradualRebalancing: true,
|
|
9261
|
-
migration:
|
|
9262
|
-
replication:
|
|
11304
|
+
migration: import_core17.DEFAULT_MIGRATION_CONFIG,
|
|
11305
|
+
replication: import_core17.DEFAULT_REPLICATION_CONFIG,
|
|
9263
11306
|
replicationEnabled: true
|
|
9264
11307
|
};
|
|
9265
11308
|
var ClusterCoordinator = class extends import_events9.EventEmitter {
|
|
@@ -9625,25 +11668,469 @@ var ClusterCoordinator = class extends import_events9.EventEmitter {
|
|
|
9625
11668
|
}
|
|
9626
11669
|
}
|
|
9627
11670
|
};
|
|
11671
|
+
|
|
11672
|
+
// src/MapWithResolver.ts
|
|
11673
|
+
var import_core18 = require("@topgunbuild/core");
|
|
11674
|
+
var MapWithResolver = class {
|
|
11675
|
+
constructor(config) {
|
|
11676
|
+
this.mapName = config.name;
|
|
11677
|
+
this.hlc = new import_core18.HLC(config.nodeId);
|
|
11678
|
+
this.map = new import_core18.LWWMap(this.hlc);
|
|
11679
|
+
this.resolverService = config.resolverService;
|
|
11680
|
+
this.onRejection = config.onRejection;
|
|
11681
|
+
}
|
|
11682
|
+
/**
|
|
11683
|
+
* Get the map name.
|
|
11684
|
+
*/
|
|
11685
|
+
get name() {
|
|
11686
|
+
return this.mapName;
|
|
11687
|
+
}
|
|
11688
|
+
/**
|
|
11689
|
+
* Get the underlying LWWMap.
|
|
11690
|
+
*/
|
|
11691
|
+
get rawMap() {
|
|
11692
|
+
return this.map;
|
|
11693
|
+
}
|
|
11694
|
+
/**
|
|
11695
|
+
* Get a value by key.
|
|
11696
|
+
*/
|
|
11697
|
+
get(key) {
|
|
11698
|
+
return this.map.get(key);
|
|
11699
|
+
}
|
|
11700
|
+
/**
|
|
11701
|
+
* Get the full record for a key.
|
|
11702
|
+
*/
|
|
11703
|
+
getRecord(key) {
|
|
11704
|
+
return this.map.getRecord(key);
|
|
11705
|
+
}
|
|
11706
|
+
/**
|
|
11707
|
+
* Get the timestamp for a key.
|
|
11708
|
+
*/
|
|
11709
|
+
getTimestamp(key) {
|
|
11710
|
+
return this.map.getRecord(key)?.timestamp;
|
|
11711
|
+
}
|
|
11712
|
+
/**
|
|
11713
|
+
* Set a value locally (no resolver).
|
|
11714
|
+
* Use for server-initiated writes.
|
|
11715
|
+
*/
|
|
11716
|
+
set(key, value, ttlMs) {
|
|
11717
|
+
return this.map.set(key, value, ttlMs);
|
|
11718
|
+
}
|
|
11719
|
+
/**
|
|
11720
|
+
* Set a value with conflict resolution.
|
|
11721
|
+
* Use for client-initiated writes.
|
|
11722
|
+
*
|
|
11723
|
+
* @param key The key to set
|
|
11724
|
+
* @param value The new value
|
|
11725
|
+
* @param timestamp The client's timestamp
|
|
11726
|
+
* @param remoteNodeId The client's node ID
|
|
11727
|
+
* @param auth Optional authentication context
|
|
11728
|
+
* @returns Result containing applied status and merge result
|
|
11729
|
+
*/
|
|
11730
|
+
async setWithResolver(key, value, timestamp, remoteNodeId, auth) {
|
|
11731
|
+
const context = {
|
|
11732
|
+
mapName: this.mapName,
|
|
11733
|
+
key,
|
|
11734
|
+
localValue: this.map.get(key),
|
|
11735
|
+
remoteValue: value,
|
|
11736
|
+
localTimestamp: this.getTimestamp(key),
|
|
11737
|
+
remoteTimestamp: timestamp,
|
|
11738
|
+
remoteNodeId,
|
|
11739
|
+
auth,
|
|
11740
|
+
readEntry: (k) => this.map.get(k)
|
|
11741
|
+
};
|
|
11742
|
+
const result = await this.resolverService.resolve(context);
|
|
11743
|
+
switch (result.action) {
|
|
11744
|
+
case "accept": {
|
|
11745
|
+
const record2 = {
|
|
11746
|
+
value: result.value,
|
|
11747
|
+
timestamp
|
|
11748
|
+
};
|
|
11749
|
+
this.map.merge(key, record2);
|
|
11750
|
+
return { applied: true, result, record: record2 };
|
|
11751
|
+
}
|
|
11752
|
+
case "merge": {
|
|
11753
|
+
const record2 = {
|
|
11754
|
+
value: result.value,
|
|
11755
|
+
timestamp
|
|
11756
|
+
};
|
|
11757
|
+
this.map.merge(key, record2);
|
|
11758
|
+
return { applied: true, result, record: record2 };
|
|
11759
|
+
}
|
|
11760
|
+
case "reject": {
|
|
11761
|
+
if (this.onRejection) {
|
|
11762
|
+
this.onRejection({
|
|
11763
|
+
mapName: this.mapName,
|
|
11764
|
+
key,
|
|
11765
|
+
attemptedValue: value,
|
|
11766
|
+
reason: result.reason,
|
|
11767
|
+
timestamp,
|
|
11768
|
+
nodeId: remoteNodeId
|
|
11769
|
+
});
|
|
11770
|
+
}
|
|
11771
|
+
return { applied: false, result };
|
|
11772
|
+
}
|
|
11773
|
+
case "local": {
|
|
11774
|
+
return { applied: false, result };
|
|
11775
|
+
}
|
|
11776
|
+
default:
|
|
11777
|
+
const record = {
|
|
11778
|
+
value: result.value ?? value,
|
|
11779
|
+
timestamp
|
|
11780
|
+
};
|
|
11781
|
+
this.map.merge(key, record);
|
|
11782
|
+
return { applied: true, result, record };
|
|
11783
|
+
}
|
|
11784
|
+
}
|
|
11785
|
+
/**
|
|
11786
|
+
* Remove a key.
|
|
11787
|
+
*/
|
|
11788
|
+
remove(key) {
|
|
11789
|
+
return this.map.remove(key);
|
|
11790
|
+
}
|
|
11791
|
+
/**
|
|
11792
|
+
* Standard merge without resolver (for sync operations).
|
|
11793
|
+
*/
|
|
11794
|
+
merge(key, record) {
|
|
11795
|
+
return this.map.merge(key, record);
|
|
11796
|
+
}
|
|
11797
|
+
/**
|
|
11798
|
+
* Merge with resolver support.
|
|
11799
|
+
* Equivalent to setWithResolver but takes a full record.
|
|
11800
|
+
*/
|
|
11801
|
+
async mergeWithResolver(key, record, remoteNodeId, auth) {
|
|
11802
|
+
if (record.value === null) {
|
|
11803
|
+
const applied = this.map.merge(key, record);
|
|
11804
|
+
return {
|
|
11805
|
+
applied,
|
|
11806
|
+
result: applied ? { action: "accept", value: record.value } : { action: "local" },
|
|
11807
|
+
record: applied ? record : void 0
|
|
11808
|
+
};
|
|
11809
|
+
}
|
|
11810
|
+
return this.setWithResolver(
|
|
11811
|
+
key,
|
|
11812
|
+
record.value,
|
|
11813
|
+
record.timestamp,
|
|
11814
|
+
remoteNodeId,
|
|
11815
|
+
auth
|
|
11816
|
+
);
|
|
11817
|
+
}
|
|
11818
|
+
/**
|
|
11819
|
+
* Clear all data.
|
|
11820
|
+
*/
|
|
11821
|
+
clear() {
|
|
11822
|
+
this.map.clear();
|
|
11823
|
+
}
|
|
11824
|
+
/**
|
|
11825
|
+
* Get map size.
|
|
11826
|
+
*/
|
|
11827
|
+
get size() {
|
|
11828
|
+
return this.map.size;
|
|
11829
|
+
}
|
|
11830
|
+
/**
|
|
11831
|
+
* Iterate over entries.
|
|
11832
|
+
*/
|
|
11833
|
+
entries() {
|
|
11834
|
+
return this.map.entries();
|
|
11835
|
+
}
|
|
11836
|
+
/**
|
|
11837
|
+
* Get all keys.
|
|
11838
|
+
*/
|
|
11839
|
+
allKeys() {
|
|
11840
|
+
return this.map.allKeys();
|
|
11841
|
+
}
|
|
11842
|
+
/**
|
|
11843
|
+
* Subscribe to changes.
|
|
11844
|
+
*/
|
|
11845
|
+
onChange(callback) {
|
|
11846
|
+
return this.map.onChange(callback);
|
|
11847
|
+
}
|
|
11848
|
+
/**
|
|
11849
|
+
* Get MerkleTree for sync.
|
|
11850
|
+
*/
|
|
11851
|
+
getMerkleTree() {
|
|
11852
|
+
return this.map.getMerkleTree();
|
|
11853
|
+
}
|
|
11854
|
+
/**
|
|
11855
|
+
* Prune old tombstones.
|
|
11856
|
+
*/
|
|
11857
|
+
prune(olderThan) {
|
|
11858
|
+
return this.map.prune(olderThan);
|
|
11859
|
+
}
|
|
11860
|
+
};
|
|
11861
|
+
|
|
11862
|
+
// src/config/IndexConfig.ts
|
|
11863
|
+
var DEFAULT_INDEX_CONFIG = {
|
|
11864
|
+
autoIndex: false,
|
|
11865
|
+
maxAutoIndexesPerMap: 10,
|
|
11866
|
+
maps: [],
|
|
11867
|
+
logStats: false,
|
|
11868
|
+
statsLogInterval: 6e4
|
|
11869
|
+
};
|
|
11870
|
+
function validateIndexConfig(config) {
|
|
11871
|
+
const errors = [];
|
|
11872
|
+
if (config.maxAutoIndexesPerMap !== void 0) {
|
|
11873
|
+
if (typeof config.maxAutoIndexesPerMap !== "number" || config.maxAutoIndexesPerMap < 1) {
|
|
11874
|
+
errors.push("maxAutoIndexesPerMap must be a positive number");
|
|
11875
|
+
}
|
|
11876
|
+
}
|
|
11877
|
+
if (config.statsLogInterval !== void 0) {
|
|
11878
|
+
if (typeof config.statsLogInterval !== "number" || config.statsLogInterval < 1e3) {
|
|
11879
|
+
errors.push("statsLogInterval must be at least 1000ms");
|
|
11880
|
+
}
|
|
11881
|
+
}
|
|
11882
|
+
if (config.maps) {
|
|
11883
|
+
const mapNames = /* @__PURE__ */ new Set();
|
|
11884
|
+
for (const mapConfig of config.maps) {
|
|
11885
|
+
if (!mapConfig.mapName || typeof mapConfig.mapName !== "string") {
|
|
11886
|
+
errors.push("Each map config must have a valid mapName");
|
|
11887
|
+
continue;
|
|
11888
|
+
}
|
|
11889
|
+
if (mapNames.has(mapConfig.mapName)) {
|
|
11890
|
+
errors.push(`Duplicate map config for: ${mapConfig.mapName}`);
|
|
11891
|
+
}
|
|
11892
|
+
mapNames.add(mapConfig.mapName);
|
|
11893
|
+
if (!Array.isArray(mapConfig.indexes)) {
|
|
11894
|
+
errors.push(`Map ${mapConfig.mapName}: indexes must be an array`);
|
|
11895
|
+
continue;
|
|
11896
|
+
}
|
|
11897
|
+
const attrNames = /* @__PURE__ */ new Set();
|
|
11898
|
+
for (const indexDef of mapConfig.indexes) {
|
|
11899
|
+
if (!indexDef.attribute || typeof indexDef.attribute !== "string") {
|
|
11900
|
+
errors.push(`Map ${mapConfig.mapName}: index must have valid attribute`);
|
|
11901
|
+
continue;
|
|
11902
|
+
}
|
|
11903
|
+
if (!["hash", "navigable"].includes(indexDef.type)) {
|
|
11904
|
+
errors.push(
|
|
11905
|
+
`Map ${mapConfig.mapName}: index type must be 'hash' or 'navigable'`
|
|
11906
|
+
);
|
|
11907
|
+
}
|
|
11908
|
+
if (indexDef.comparator && !["number", "string", "date"].includes(indexDef.comparator)) {
|
|
11909
|
+
errors.push(
|
|
11910
|
+
`Map ${mapConfig.mapName}: comparator must be 'number', 'string', or 'date'`
|
|
11911
|
+
);
|
|
11912
|
+
}
|
|
11913
|
+
const key = `${indexDef.attribute}:${indexDef.type}`;
|
|
11914
|
+
if (attrNames.has(key)) {
|
|
11915
|
+
errors.push(
|
|
11916
|
+
`Map ${mapConfig.mapName}: duplicate ${indexDef.type} index on ${indexDef.attribute}`
|
|
11917
|
+
);
|
|
11918
|
+
}
|
|
11919
|
+
attrNames.add(key);
|
|
11920
|
+
}
|
|
11921
|
+
}
|
|
11922
|
+
}
|
|
11923
|
+
return errors;
|
|
11924
|
+
}
|
|
11925
|
+
function mergeWithDefaults(userConfig) {
|
|
11926
|
+
return {
|
|
11927
|
+
...DEFAULT_INDEX_CONFIG,
|
|
11928
|
+
...userConfig,
|
|
11929
|
+
maps: userConfig.maps ?? DEFAULT_INDEX_CONFIG.maps
|
|
11930
|
+
};
|
|
11931
|
+
}
|
|
11932
|
+
|
|
11933
|
+
// src/config/MapFactory.ts
|
|
11934
|
+
var import_core19 = require("@topgunbuild/core");
|
|
11935
|
+
var MapFactory = class {
|
|
11936
|
+
/**
|
|
11937
|
+
* Create a MapFactory.
|
|
11938
|
+
*
|
|
11939
|
+
* @param config - Server index configuration
|
|
11940
|
+
*/
|
|
11941
|
+
constructor(config) {
|
|
11942
|
+
this.config = mergeWithDefaults(config ?? {});
|
|
11943
|
+
this.mapConfigs = /* @__PURE__ */ new Map();
|
|
11944
|
+
for (const mapConfig of this.config.maps ?? []) {
|
|
11945
|
+
this.mapConfigs.set(mapConfig.mapName, mapConfig);
|
|
11946
|
+
}
|
|
11947
|
+
}
|
|
11948
|
+
/**
|
|
11949
|
+
* Create an LWWMap or IndexedLWWMap based on configuration.
|
|
11950
|
+
*
|
|
11951
|
+
* @param mapName - Name of the map
|
|
11952
|
+
* @param hlc - Hybrid Logical Clock instance
|
|
11953
|
+
* @returns LWWMap or IndexedLWWMap depending on configuration
|
|
11954
|
+
*/
|
|
11955
|
+
createLWWMap(mapName, hlc) {
|
|
11956
|
+
const mapConfig = this.mapConfigs.get(mapName);
|
|
11957
|
+
if (!mapConfig || mapConfig.indexes.length === 0) {
|
|
11958
|
+
return new import_core19.LWWMap(hlc);
|
|
11959
|
+
}
|
|
11960
|
+
const map = new import_core19.IndexedLWWMap(hlc);
|
|
11961
|
+
for (const indexDef of mapConfig.indexes) {
|
|
11962
|
+
this.addIndexToLWWMap(map, indexDef);
|
|
11963
|
+
}
|
|
11964
|
+
return map;
|
|
11965
|
+
}
|
|
11966
|
+
/**
|
|
11967
|
+
* Create an ORMap or IndexedORMap based on configuration.
|
|
11968
|
+
*
|
|
11969
|
+
* @param mapName - Name of the map
|
|
11970
|
+
* @param hlc - Hybrid Logical Clock instance
|
|
11971
|
+
* @returns ORMap or IndexedORMap depending on configuration
|
|
11972
|
+
*/
|
|
11973
|
+
createORMap(mapName, hlc) {
|
|
11974
|
+
const mapConfig = this.mapConfigs.get(mapName);
|
|
11975
|
+
if (!mapConfig || mapConfig.indexes.length === 0) {
|
|
11976
|
+
return new import_core19.ORMap(hlc);
|
|
11977
|
+
}
|
|
11978
|
+
const map = new import_core19.IndexedORMap(hlc);
|
|
11979
|
+
for (const indexDef of mapConfig.indexes) {
|
|
11980
|
+
this.addIndexToORMap(map, indexDef);
|
|
11981
|
+
}
|
|
11982
|
+
return map;
|
|
11983
|
+
}
|
|
11984
|
+
/**
|
|
11985
|
+
* Add an index to an IndexedLWWMap based on definition.
|
|
11986
|
+
*/
|
|
11987
|
+
addIndexToLWWMap(map, indexDef) {
|
|
11988
|
+
const attribute = this.createAttribute(indexDef.attribute);
|
|
11989
|
+
if (indexDef.type === "hash") {
|
|
11990
|
+
map.addHashIndex(attribute);
|
|
11991
|
+
} else if (indexDef.type === "navigable") {
|
|
11992
|
+
const navAttribute = attribute;
|
|
11993
|
+
const comparator = this.createComparator(indexDef.comparator);
|
|
11994
|
+
map.addNavigableIndex(navAttribute, comparator);
|
|
11995
|
+
}
|
|
11996
|
+
}
|
|
11997
|
+
/**
|
|
11998
|
+
* Add an index to an IndexedORMap based on definition.
|
|
11999
|
+
*/
|
|
12000
|
+
addIndexToORMap(map, indexDef) {
|
|
12001
|
+
const attribute = this.createAttribute(indexDef.attribute);
|
|
12002
|
+
if (indexDef.type === "hash") {
|
|
12003
|
+
map.addHashIndex(attribute);
|
|
12004
|
+
} else if (indexDef.type === "navigable") {
|
|
12005
|
+
const navAttribute = attribute;
|
|
12006
|
+
const comparator = this.createComparator(indexDef.comparator);
|
|
12007
|
+
map.addNavigableIndex(navAttribute, comparator);
|
|
12008
|
+
}
|
|
12009
|
+
}
|
|
12010
|
+
/**
|
|
12011
|
+
* Create an Attribute for extracting values from records.
|
|
12012
|
+
* Supports dot notation for nested paths.
|
|
12013
|
+
*/
|
|
12014
|
+
createAttribute(path) {
|
|
12015
|
+
return (0, import_core19.simpleAttribute)(path, (record) => {
|
|
12016
|
+
return this.getNestedValue(record, path);
|
|
12017
|
+
});
|
|
12018
|
+
}
|
|
12019
|
+
/**
|
|
12020
|
+
* Get a nested value from an object using dot notation.
|
|
12021
|
+
*
|
|
12022
|
+
* @param obj - Object to extract value from
|
|
12023
|
+
* @param path - Dot-notation path (e.g., "user.email")
|
|
12024
|
+
* @returns Value at the path or undefined
|
|
12025
|
+
*/
|
|
12026
|
+
getNestedValue(obj, path) {
|
|
12027
|
+
if (obj === null || obj === void 0) {
|
|
12028
|
+
return void 0;
|
|
12029
|
+
}
|
|
12030
|
+
const parts = path.split(".");
|
|
12031
|
+
let current = obj;
|
|
12032
|
+
for (const part of parts) {
|
|
12033
|
+
if (current === void 0 || current === null) {
|
|
12034
|
+
return void 0;
|
|
12035
|
+
}
|
|
12036
|
+
if (typeof current !== "object") {
|
|
12037
|
+
return void 0;
|
|
12038
|
+
}
|
|
12039
|
+
current = current[part];
|
|
12040
|
+
}
|
|
12041
|
+
return current;
|
|
12042
|
+
}
|
|
12043
|
+
/**
|
|
12044
|
+
* Create a comparator function for navigable indexes.
|
|
12045
|
+
*/
|
|
12046
|
+
createComparator(type) {
|
|
12047
|
+
switch (type) {
|
|
12048
|
+
case "number":
|
|
12049
|
+
return (a, b) => {
|
|
12050
|
+
const numA = typeof a === "number" ? a : parseFloat(String(a));
|
|
12051
|
+
const numB = typeof b === "number" ? b : parseFloat(String(b));
|
|
12052
|
+
return numA - numB;
|
|
12053
|
+
};
|
|
12054
|
+
case "date":
|
|
12055
|
+
return (a, b) => {
|
|
12056
|
+
const dateA = new Date(a).getTime();
|
|
12057
|
+
const dateB = new Date(b).getTime();
|
|
12058
|
+
return dateA - dateB;
|
|
12059
|
+
};
|
|
12060
|
+
case "string":
|
|
12061
|
+
return (a, b) => {
|
|
12062
|
+
const strA = String(a);
|
|
12063
|
+
const strB = String(b);
|
|
12064
|
+
return strA.localeCompare(strB);
|
|
12065
|
+
};
|
|
12066
|
+
default:
|
|
12067
|
+
return void 0;
|
|
12068
|
+
}
|
|
12069
|
+
}
|
|
12070
|
+
/**
|
|
12071
|
+
* Check if a map should be indexed based on configuration.
|
|
12072
|
+
*
|
|
12073
|
+
* @param mapName - Name of the map
|
|
12074
|
+
* @returns true if map has index configuration
|
|
12075
|
+
*/
|
|
12076
|
+
hasIndexConfig(mapName) {
|
|
12077
|
+
const config = this.mapConfigs.get(mapName);
|
|
12078
|
+
return config !== void 0 && config.indexes.length > 0;
|
|
12079
|
+
}
|
|
12080
|
+
/**
|
|
12081
|
+
* Get index configuration for a map.
|
|
12082
|
+
*
|
|
12083
|
+
* @param mapName - Name of the map
|
|
12084
|
+
* @returns Map index config or undefined
|
|
12085
|
+
*/
|
|
12086
|
+
getMapConfig(mapName) {
|
|
12087
|
+
return this.mapConfigs.get(mapName);
|
|
12088
|
+
}
|
|
12089
|
+
/**
|
|
12090
|
+
* Get all configured map names.
|
|
12091
|
+
*
|
|
12092
|
+
* @returns Array of map names with index configuration
|
|
12093
|
+
*/
|
|
12094
|
+
getConfiguredMaps() {
|
|
12095
|
+
return Array.from(this.mapConfigs.keys());
|
|
12096
|
+
}
|
|
12097
|
+
/**
|
|
12098
|
+
* Get the full server index configuration.
|
|
12099
|
+
*/
|
|
12100
|
+
getConfig() {
|
|
12101
|
+
return this.config;
|
|
12102
|
+
}
|
|
12103
|
+
};
|
|
9628
12104
|
// Annotate the CommonJS export names for ESM import in node:
|
|
9629
12105
|
0 && (module.exports = {
|
|
9630
12106
|
BufferPool,
|
|
9631
12107
|
ClusterCoordinator,
|
|
9632
12108
|
ClusterManager,
|
|
12109
|
+
ConflictResolverHandler,
|
|
12110
|
+
ConflictResolverService,
|
|
9633
12111
|
ConnectionRateLimiter,
|
|
9634
12112
|
DEFAULT_CLUSTER_COORDINATOR_CONFIG,
|
|
12113
|
+
DEFAULT_CONFLICT_RESOLVER_CONFIG,
|
|
12114
|
+
DEFAULT_INDEX_CONFIG,
|
|
12115
|
+
DEFAULT_JOURNAL_SERVICE_CONFIG,
|
|
9635
12116
|
DEFAULT_LAG_TRACKER_CONFIG,
|
|
12117
|
+
DEFAULT_SANDBOX_CONFIG,
|
|
12118
|
+
EntryProcessorHandler,
|
|
12119
|
+
EventJournalService,
|
|
9636
12120
|
FilterTasklet,
|
|
9637
12121
|
ForEachTasklet,
|
|
9638
12122
|
IteratorTasklet,
|
|
9639
12123
|
LagTracker,
|
|
9640
12124
|
LockManager,
|
|
12125
|
+
MapFactory,
|
|
9641
12126
|
MapTasklet,
|
|
12127
|
+
MapWithResolver,
|
|
9642
12128
|
MemoryServerAdapter,
|
|
9643
12129
|
MigrationManager,
|
|
9644
12130
|
ObjectPool,
|
|
9645
12131
|
PartitionService,
|
|
9646
12132
|
PostgresAdapter,
|
|
12133
|
+
ProcessorSandbox,
|
|
9647
12134
|
RateLimitInterceptor,
|
|
9648
12135
|
ReduceTasklet,
|
|
9649
12136
|
ReplicationPipeline,
|
|
@@ -9666,10 +12153,12 @@ var ClusterCoordinator = class extends import_events9.EventEmitter {
|
|
|
9666
12153
|
getNativeStats,
|
|
9667
12154
|
logNativeStatus,
|
|
9668
12155
|
logger,
|
|
12156
|
+
mergeWithDefaults,
|
|
9669
12157
|
setGlobalBufferPool,
|
|
9670
12158
|
setGlobalEventPayloadPool,
|
|
9671
12159
|
setGlobalMessagePool,
|
|
9672
12160
|
setGlobalRecordPool,
|
|
9673
|
-
setGlobalTimestampPool
|
|
12161
|
+
setGlobalTimestampPool,
|
|
12162
|
+
validateIndexConfig
|
|
9674
12163
|
});
|
|
9675
12164
|
//# sourceMappingURL=index.js.map
|