@topgunbuild/server 0.3.0 → 0.4.0

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