@topgunbuild/client 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +1518 -63
- package/dist/index.d.ts +1518 -63
- package/dist/index.js +3331 -137
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3325 -129
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -10
- package/LICENSE +0 -97
package/dist/index.mjs
CHANGED
|
@@ -203,6 +203,464 @@ var DEFAULT_BACKPRESSURE_CONFIG = {
|
|
|
203
203
|
lowWaterMark: 0.5
|
|
204
204
|
};
|
|
205
205
|
|
|
206
|
+
// src/connection/SingleServerProvider.ts
|
|
207
|
+
var DEFAULT_CONFIG = {
|
|
208
|
+
maxReconnectAttempts: 10,
|
|
209
|
+
reconnectDelayMs: 1e3,
|
|
210
|
+
backoffMultiplier: 2,
|
|
211
|
+
maxReconnectDelayMs: 3e4
|
|
212
|
+
};
|
|
213
|
+
var SingleServerProvider = class {
|
|
214
|
+
constructor(config) {
|
|
215
|
+
this.ws = null;
|
|
216
|
+
this.reconnectAttempts = 0;
|
|
217
|
+
this.reconnectTimer = null;
|
|
218
|
+
this.isClosing = false;
|
|
219
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
220
|
+
this.url = config.url;
|
|
221
|
+
this.config = {
|
|
222
|
+
url: config.url,
|
|
223
|
+
maxReconnectAttempts: config.maxReconnectAttempts ?? DEFAULT_CONFIG.maxReconnectAttempts,
|
|
224
|
+
reconnectDelayMs: config.reconnectDelayMs ?? DEFAULT_CONFIG.reconnectDelayMs,
|
|
225
|
+
backoffMultiplier: config.backoffMultiplier ?? DEFAULT_CONFIG.backoffMultiplier,
|
|
226
|
+
maxReconnectDelayMs: config.maxReconnectDelayMs ?? DEFAULT_CONFIG.maxReconnectDelayMs
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Connect to the WebSocket server.
|
|
231
|
+
*/
|
|
232
|
+
async connect() {
|
|
233
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
this.isClosing = false;
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
try {
|
|
239
|
+
this.ws = new WebSocket(this.url);
|
|
240
|
+
this.ws.binaryType = "arraybuffer";
|
|
241
|
+
this.ws.onopen = () => {
|
|
242
|
+
this.reconnectAttempts = 0;
|
|
243
|
+
logger.info({ url: this.url }, "SingleServerProvider connected");
|
|
244
|
+
this.emit("connected", "default");
|
|
245
|
+
resolve();
|
|
246
|
+
};
|
|
247
|
+
this.ws.onerror = (error) => {
|
|
248
|
+
logger.error({ err: error, url: this.url }, "SingleServerProvider WebSocket error");
|
|
249
|
+
this.emit("error", error);
|
|
250
|
+
};
|
|
251
|
+
this.ws.onclose = (event) => {
|
|
252
|
+
logger.info({ url: this.url, code: event.code }, "SingleServerProvider disconnected");
|
|
253
|
+
this.emit("disconnected", "default");
|
|
254
|
+
if (!this.isClosing) {
|
|
255
|
+
this.scheduleReconnect();
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
this.ws.onmessage = (event) => {
|
|
259
|
+
this.emit("message", "default", event.data);
|
|
260
|
+
};
|
|
261
|
+
const timeoutId = setTimeout(() => {
|
|
262
|
+
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
|
263
|
+
this.ws.close();
|
|
264
|
+
reject(new Error(`Connection timeout to ${this.url}`));
|
|
265
|
+
}
|
|
266
|
+
}, this.config.reconnectDelayMs * 5);
|
|
267
|
+
const originalOnOpen = this.ws.onopen;
|
|
268
|
+
const wsRef = this.ws;
|
|
269
|
+
this.ws.onopen = (ev) => {
|
|
270
|
+
clearTimeout(timeoutId);
|
|
271
|
+
if (originalOnOpen) {
|
|
272
|
+
originalOnOpen.call(wsRef, ev);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
} catch (error) {
|
|
276
|
+
reject(error);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Get connection for a specific key.
|
|
282
|
+
* In single-server mode, key is ignored.
|
|
283
|
+
*/
|
|
284
|
+
getConnection(_key) {
|
|
285
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
286
|
+
throw new Error("Not connected");
|
|
287
|
+
}
|
|
288
|
+
return this.ws;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Get any available connection.
|
|
292
|
+
*/
|
|
293
|
+
getAnyConnection() {
|
|
294
|
+
return this.getConnection("");
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Check if connected.
|
|
298
|
+
*/
|
|
299
|
+
isConnected() {
|
|
300
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Get connected node IDs.
|
|
304
|
+
* Single-server mode returns ['default'] when connected.
|
|
305
|
+
*/
|
|
306
|
+
getConnectedNodes() {
|
|
307
|
+
return this.isConnected() ? ["default"] : [];
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Subscribe to connection events.
|
|
311
|
+
*/
|
|
312
|
+
on(event, handler2) {
|
|
313
|
+
if (!this.listeners.has(event)) {
|
|
314
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
315
|
+
}
|
|
316
|
+
this.listeners.get(event).add(handler2);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Unsubscribe from connection events.
|
|
320
|
+
*/
|
|
321
|
+
off(event, handler2) {
|
|
322
|
+
this.listeners.get(event)?.delete(handler2);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Send data via the WebSocket connection.
|
|
326
|
+
* In single-server mode, key parameter is ignored.
|
|
327
|
+
*/
|
|
328
|
+
send(data, _key) {
|
|
329
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
330
|
+
throw new Error("Not connected");
|
|
331
|
+
}
|
|
332
|
+
this.ws.send(data);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Close the WebSocket connection.
|
|
336
|
+
*/
|
|
337
|
+
async close() {
|
|
338
|
+
this.isClosing = true;
|
|
339
|
+
if (this.reconnectTimer) {
|
|
340
|
+
clearTimeout(this.reconnectTimer);
|
|
341
|
+
this.reconnectTimer = null;
|
|
342
|
+
}
|
|
343
|
+
if (this.ws) {
|
|
344
|
+
this.ws.onclose = null;
|
|
345
|
+
this.ws.onerror = null;
|
|
346
|
+
this.ws.onmessage = null;
|
|
347
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
348
|
+
this.ws.close();
|
|
349
|
+
}
|
|
350
|
+
this.ws = null;
|
|
351
|
+
}
|
|
352
|
+
logger.info({ url: this.url }, "SingleServerProvider closed");
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Emit an event to all listeners.
|
|
356
|
+
*/
|
|
357
|
+
emit(event, ...args) {
|
|
358
|
+
const handlers = this.listeners.get(event);
|
|
359
|
+
if (handlers) {
|
|
360
|
+
for (const handler2 of handlers) {
|
|
361
|
+
try {
|
|
362
|
+
handler2(...args);
|
|
363
|
+
} catch (err) {
|
|
364
|
+
logger.error({ err, event }, "Error in SingleServerProvider event handler");
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Schedule a reconnection attempt with exponential backoff.
|
|
371
|
+
*/
|
|
372
|
+
scheduleReconnect() {
|
|
373
|
+
if (this.reconnectTimer) {
|
|
374
|
+
clearTimeout(this.reconnectTimer);
|
|
375
|
+
this.reconnectTimer = null;
|
|
376
|
+
}
|
|
377
|
+
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
378
|
+
logger.error(
|
|
379
|
+
{ attempts: this.reconnectAttempts, url: this.url },
|
|
380
|
+
"SingleServerProvider max reconnect attempts reached"
|
|
381
|
+
);
|
|
382
|
+
this.emit("error", new Error("Max reconnection attempts reached"));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const delay = this.calculateBackoffDelay();
|
|
386
|
+
logger.info(
|
|
387
|
+
{ delay, attempt: this.reconnectAttempts, url: this.url },
|
|
388
|
+
`SingleServerProvider scheduling reconnect in ${delay}ms`
|
|
389
|
+
);
|
|
390
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
391
|
+
this.reconnectTimer = null;
|
|
392
|
+
this.reconnectAttempts++;
|
|
393
|
+
try {
|
|
394
|
+
await this.connect();
|
|
395
|
+
this.emit("reconnected", "default");
|
|
396
|
+
} catch (error) {
|
|
397
|
+
logger.error({ err: error }, "SingleServerProvider reconnection failed");
|
|
398
|
+
this.scheduleReconnect();
|
|
399
|
+
}
|
|
400
|
+
}, delay);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Calculate backoff delay with exponential increase.
|
|
404
|
+
*/
|
|
405
|
+
calculateBackoffDelay() {
|
|
406
|
+
const { reconnectDelayMs, backoffMultiplier, maxReconnectDelayMs } = this.config;
|
|
407
|
+
let delay = reconnectDelayMs * Math.pow(backoffMultiplier, this.reconnectAttempts);
|
|
408
|
+
delay = Math.min(delay, maxReconnectDelayMs);
|
|
409
|
+
delay = delay * (0.5 + Math.random());
|
|
410
|
+
return Math.floor(delay);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Get the WebSocket URL this provider connects to.
|
|
414
|
+
*/
|
|
415
|
+
getUrl() {
|
|
416
|
+
return this.url;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Get current reconnection attempt count.
|
|
420
|
+
*/
|
|
421
|
+
getReconnectAttempts() {
|
|
422
|
+
return this.reconnectAttempts;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Reset reconnection counter.
|
|
426
|
+
* Called externally after successful authentication.
|
|
427
|
+
*/
|
|
428
|
+
resetReconnectAttempts() {
|
|
429
|
+
this.reconnectAttempts = 0;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// src/ConflictResolverClient.ts
|
|
434
|
+
var _ConflictResolverClient = class _ConflictResolverClient {
|
|
435
|
+
// 10 seconds
|
|
436
|
+
constructor(syncEngine) {
|
|
437
|
+
this.rejectionListeners = /* @__PURE__ */ new Set();
|
|
438
|
+
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
439
|
+
this.syncEngine = syncEngine;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Register a conflict resolver on the server.
|
|
443
|
+
*
|
|
444
|
+
* @param mapName The map to register the resolver for
|
|
445
|
+
* @param resolver The resolver definition
|
|
446
|
+
* @returns Promise resolving to registration result
|
|
447
|
+
*
|
|
448
|
+
* @example
|
|
449
|
+
* ```typescript
|
|
450
|
+
* // Register a first-write-wins resolver for bookings
|
|
451
|
+
* await client.resolvers.register('bookings', {
|
|
452
|
+
* name: 'first-write-wins',
|
|
453
|
+
* code: `
|
|
454
|
+
* if (context.localValue !== undefined) {
|
|
455
|
+
* return { action: 'reject', reason: 'Slot already booked' };
|
|
456
|
+
* }
|
|
457
|
+
* return { action: 'accept', value: context.remoteValue };
|
|
458
|
+
* `,
|
|
459
|
+
* priority: 100,
|
|
460
|
+
* });
|
|
461
|
+
* ```
|
|
462
|
+
*/
|
|
463
|
+
async register(mapName, resolver) {
|
|
464
|
+
const requestId = crypto.randomUUID();
|
|
465
|
+
return new Promise((resolve, reject) => {
|
|
466
|
+
const timeout = setTimeout(() => {
|
|
467
|
+
this.pendingRequests.delete(requestId);
|
|
468
|
+
reject(new Error("Register resolver request timed out"));
|
|
469
|
+
}, _ConflictResolverClient.REQUEST_TIMEOUT);
|
|
470
|
+
this.pendingRequests.set(requestId, {
|
|
471
|
+
resolve: (result) => {
|
|
472
|
+
clearTimeout(timeout);
|
|
473
|
+
resolve(result);
|
|
474
|
+
},
|
|
475
|
+
reject,
|
|
476
|
+
timeout
|
|
477
|
+
});
|
|
478
|
+
try {
|
|
479
|
+
this.syncEngine.send({
|
|
480
|
+
type: "REGISTER_RESOLVER",
|
|
481
|
+
requestId,
|
|
482
|
+
mapName,
|
|
483
|
+
resolver: {
|
|
484
|
+
name: resolver.name,
|
|
485
|
+
code: resolver.code || "",
|
|
486
|
+
priority: resolver.priority,
|
|
487
|
+
keyPattern: resolver.keyPattern
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
} catch {
|
|
491
|
+
this.pendingRequests.delete(requestId);
|
|
492
|
+
clearTimeout(timeout);
|
|
493
|
+
resolve({ success: false, error: "Not connected to server" });
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Unregister a conflict resolver from the server.
|
|
499
|
+
*
|
|
500
|
+
* @param mapName The map the resolver is registered for
|
|
501
|
+
* @param resolverName The name of the resolver to unregister
|
|
502
|
+
* @returns Promise resolving to unregistration result
|
|
503
|
+
*/
|
|
504
|
+
async unregister(mapName, resolverName) {
|
|
505
|
+
const requestId = crypto.randomUUID();
|
|
506
|
+
return new Promise((resolve, reject) => {
|
|
507
|
+
const timeout = setTimeout(() => {
|
|
508
|
+
this.pendingRequests.delete(requestId);
|
|
509
|
+
reject(new Error("Unregister resolver request timed out"));
|
|
510
|
+
}, _ConflictResolverClient.REQUEST_TIMEOUT);
|
|
511
|
+
this.pendingRequests.set(requestId, {
|
|
512
|
+
resolve: (result) => {
|
|
513
|
+
clearTimeout(timeout);
|
|
514
|
+
resolve(result);
|
|
515
|
+
},
|
|
516
|
+
reject,
|
|
517
|
+
timeout
|
|
518
|
+
});
|
|
519
|
+
try {
|
|
520
|
+
this.syncEngine.send({
|
|
521
|
+
type: "UNREGISTER_RESOLVER",
|
|
522
|
+
requestId,
|
|
523
|
+
mapName,
|
|
524
|
+
resolverName
|
|
525
|
+
});
|
|
526
|
+
} catch {
|
|
527
|
+
this.pendingRequests.delete(requestId);
|
|
528
|
+
clearTimeout(timeout);
|
|
529
|
+
resolve({ success: false, error: "Not connected to server" });
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* List registered conflict resolvers on the server.
|
|
535
|
+
*
|
|
536
|
+
* @param mapName Optional - filter by map name
|
|
537
|
+
* @returns Promise resolving to list of resolver info
|
|
538
|
+
*/
|
|
539
|
+
async list(mapName) {
|
|
540
|
+
const requestId = crypto.randomUUID();
|
|
541
|
+
return new Promise((resolve, reject) => {
|
|
542
|
+
const timeout = setTimeout(() => {
|
|
543
|
+
this.pendingRequests.delete(requestId);
|
|
544
|
+
reject(new Error("List resolvers request timed out"));
|
|
545
|
+
}, _ConflictResolverClient.REQUEST_TIMEOUT);
|
|
546
|
+
this.pendingRequests.set(requestId, {
|
|
547
|
+
resolve: (result) => {
|
|
548
|
+
clearTimeout(timeout);
|
|
549
|
+
resolve(result.resolvers);
|
|
550
|
+
},
|
|
551
|
+
reject,
|
|
552
|
+
timeout
|
|
553
|
+
});
|
|
554
|
+
try {
|
|
555
|
+
this.syncEngine.send({
|
|
556
|
+
type: "LIST_RESOLVERS",
|
|
557
|
+
requestId,
|
|
558
|
+
mapName
|
|
559
|
+
});
|
|
560
|
+
} catch {
|
|
561
|
+
this.pendingRequests.delete(requestId);
|
|
562
|
+
clearTimeout(timeout);
|
|
563
|
+
resolve([]);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Subscribe to merge rejection events.
|
|
569
|
+
*
|
|
570
|
+
* @param listener Callback for rejection events
|
|
571
|
+
* @returns Unsubscribe function
|
|
572
|
+
*
|
|
573
|
+
* @example
|
|
574
|
+
* ```typescript
|
|
575
|
+
* const unsubscribe = client.resolvers.onRejection((rejection) => {
|
|
576
|
+
* console.log(`Merge rejected for ${rejection.key}: ${rejection.reason}`);
|
|
577
|
+
* // Optionally refresh the local value
|
|
578
|
+
* });
|
|
579
|
+
*
|
|
580
|
+
* // Later...
|
|
581
|
+
* unsubscribe();
|
|
582
|
+
* ```
|
|
583
|
+
*/
|
|
584
|
+
onRejection(listener) {
|
|
585
|
+
this.rejectionListeners.add(listener);
|
|
586
|
+
return () => this.rejectionListeners.delete(listener);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Handle REGISTER_RESOLVER_RESPONSE from server.
|
|
590
|
+
* Called by SyncEngine.
|
|
591
|
+
*/
|
|
592
|
+
handleRegisterResponse(message) {
|
|
593
|
+
const pending = this.pendingRequests.get(message.requestId);
|
|
594
|
+
if (pending) {
|
|
595
|
+
this.pendingRequests.delete(message.requestId);
|
|
596
|
+
pending.resolve({ success: message.success, error: message.error });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Handle UNREGISTER_RESOLVER_RESPONSE from server.
|
|
601
|
+
* Called by SyncEngine.
|
|
602
|
+
*/
|
|
603
|
+
handleUnregisterResponse(message) {
|
|
604
|
+
const pending = this.pendingRequests.get(message.requestId);
|
|
605
|
+
if (pending) {
|
|
606
|
+
this.pendingRequests.delete(message.requestId);
|
|
607
|
+
pending.resolve({ success: message.success, error: message.error });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Handle LIST_RESOLVERS_RESPONSE from server.
|
|
612
|
+
* Called by SyncEngine.
|
|
613
|
+
*/
|
|
614
|
+
handleListResponse(message) {
|
|
615
|
+
const pending = this.pendingRequests.get(message.requestId);
|
|
616
|
+
if (pending) {
|
|
617
|
+
this.pendingRequests.delete(message.requestId);
|
|
618
|
+
pending.resolve({ resolvers: message.resolvers });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Handle MERGE_REJECTED from server.
|
|
623
|
+
* Called by SyncEngine.
|
|
624
|
+
*/
|
|
625
|
+
handleMergeRejected(message) {
|
|
626
|
+
const rejection = {
|
|
627
|
+
mapName: message.mapName,
|
|
628
|
+
key: message.key,
|
|
629
|
+
attemptedValue: message.attemptedValue,
|
|
630
|
+
reason: message.reason,
|
|
631
|
+
timestamp: message.timestamp,
|
|
632
|
+
nodeId: ""
|
|
633
|
+
// Not provided by server in this message
|
|
634
|
+
};
|
|
635
|
+
logger.debug({ rejection }, "Merge rejected by server");
|
|
636
|
+
for (const listener of this.rejectionListeners) {
|
|
637
|
+
try {
|
|
638
|
+
listener(rejection);
|
|
639
|
+
} catch (e) {
|
|
640
|
+
logger.error({ error: e }, "Error in rejection listener");
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Clear all pending requests (e.g., on disconnect).
|
|
646
|
+
*/
|
|
647
|
+
clearPending() {
|
|
648
|
+
for (const [requestId, pending] of this.pendingRequests) {
|
|
649
|
+
clearTimeout(pending.timeout);
|
|
650
|
+
pending.reject(new Error("Connection lost"));
|
|
651
|
+
}
|
|
652
|
+
this.pendingRequests.clear();
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Get the number of registered rejection listeners.
|
|
656
|
+
*/
|
|
657
|
+
get rejectionListenerCount() {
|
|
658
|
+
return this.rejectionListeners.size;
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
_ConflictResolverClient.REQUEST_TIMEOUT = 1e4;
|
|
662
|
+
var ConflictResolverClient = _ConflictResolverClient;
|
|
663
|
+
|
|
206
664
|
// src/SyncEngine.ts
|
|
207
665
|
var DEFAULT_BACKOFF_CONFIG = {
|
|
208
666
|
initialDelayMs: 1e3,
|
|
@@ -211,7 +669,7 @@ var DEFAULT_BACKOFF_CONFIG = {
|
|
|
211
669
|
jitter: true,
|
|
212
670
|
maxRetries: 10
|
|
213
671
|
};
|
|
214
|
-
var
|
|
672
|
+
var _SyncEngine = class _SyncEngine {
|
|
215
673
|
constructor(config) {
|
|
216
674
|
this.websocket = null;
|
|
217
675
|
this.opLog = [];
|
|
@@ -234,8 +692,28 @@ var SyncEngine = class {
|
|
|
234
692
|
this.backpressureListeners = /* @__PURE__ */ new Map();
|
|
235
693
|
// Write Concern state (Phase 5.01)
|
|
236
694
|
this.pendingWriteConcernPromises = /* @__PURE__ */ new Map();
|
|
695
|
+
// ============================================
|
|
696
|
+
// PN Counter Methods (Phase 5.2)
|
|
697
|
+
// ============================================
|
|
698
|
+
/** Counter update listeners by name */
|
|
699
|
+
this.counterUpdateListeners = /* @__PURE__ */ new Map();
|
|
700
|
+
// ============================================
|
|
701
|
+
// Entry Processor Methods (Phase 5.03)
|
|
702
|
+
// ============================================
|
|
703
|
+
/** Pending entry processor requests by requestId */
|
|
704
|
+
this.pendingProcessorRequests = /* @__PURE__ */ new Map();
|
|
705
|
+
/** Pending batch entry processor requests by requestId */
|
|
706
|
+
this.pendingBatchProcessorRequests = /* @__PURE__ */ new Map();
|
|
707
|
+
// ============================================
|
|
708
|
+
// Event Journal Methods (Phase 5.04)
|
|
709
|
+
// ============================================
|
|
710
|
+
/** Message listeners for journal and other generic messages */
|
|
711
|
+
this.messageListeners = /* @__PURE__ */ new Set();
|
|
712
|
+
if (!config.serverUrl && !config.connectionProvider) {
|
|
713
|
+
throw new Error("SyncEngine requires either serverUrl or connectionProvider");
|
|
714
|
+
}
|
|
237
715
|
this.nodeId = config.nodeId;
|
|
238
|
-
this.serverUrl = config.serverUrl;
|
|
716
|
+
this.serverUrl = config.serverUrl || "";
|
|
239
717
|
this.storageAdapter = config.storageAdapter;
|
|
240
718
|
this.hlc = new HLC(this.nodeId);
|
|
241
719
|
this.stateMachine = new SyncStateMachine();
|
|
@@ -252,7 +730,16 @@ var SyncEngine = class {
|
|
|
252
730
|
...DEFAULT_BACKPRESSURE_CONFIG,
|
|
253
731
|
...config.backpressure
|
|
254
732
|
};
|
|
255
|
-
|
|
733
|
+
if (config.connectionProvider) {
|
|
734
|
+
this.connectionProvider = config.connectionProvider;
|
|
735
|
+
this.useConnectionProvider = true;
|
|
736
|
+
this.initConnectionProvider();
|
|
737
|
+
} else {
|
|
738
|
+
this.connectionProvider = new SingleServerProvider({ url: config.serverUrl });
|
|
739
|
+
this.useConnectionProvider = false;
|
|
740
|
+
this.initConnection();
|
|
741
|
+
}
|
|
742
|
+
this.conflictResolverClient = new ConflictResolverClient(this);
|
|
256
743
|
this.loadOpLog();
|
|
257
744
|
}
|
|
258
745
|
// ============================================
|
|
@@ -303,6 +790,65 @@ var SyncEngine = class {
|
|
|
303
790
|
// ============================================
|
|
304
791
|
// Connection Management
|
|
305
792
|
// ============================================
|
|
793
|
+
/**
|
|
794
|
+
* Initialize connection using IConnectionProvider (Phase 4.5 cluster mode).
|
|
795
|
+
* Sets up event handlers for the connection provider.
|
|
796
|
+
*/
|
|
797
|
+
initConnectionProvider() {
|
|
798
|
+
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
799
|
+
this.connectionProvider.on("connected", (_nodeId) => {
|
|
800
|
+
if (this.authToken || this.tokenProvider) {
|
|
801
|
+
logger.info("ConnectionProvider connected. Sending auth...");
|
|
802
|
+
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
803
|
+
this.sendAuth();
|
|
804
|
+
} else {
|
|
805
|
+
logger.info("ConnectionProvider connected. Waiting for auth token...");
|
|
806
|
+
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
this.connectionProvider.on("disconnected", (_nodeId) => {
|
|
810
|
+
logger.info("ConnectionProvider disconnected.");
|
|
811
|
+
this.stopHeartbeat();
|
|
812
|
+
this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
813
|
+
});
|
|
814
|
+
this.connectionProvider.on("reconnected", (_nodeId) => {
|
|
815
|
+
logger.info("ConnectionProvider reconnected.");
|
|
816
|
+
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
817
|
+
if (this.authToken || this.tokenProvider) {
|
|
818
|
+
this.stateMachine.transition("AUTHENTICATING" /* AUTHENTICATING */);
|
|
819
|
+
this.sendAuth();
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
this.connectionProvider.on("message", (_nodeId, data) => {
|
|
823
|
+
let message;
|
|
824
|
+
if (data instanceof ArrayBuffer) {
|
|
825
|
+
message = deserialize(new Uint8Array(data));
|
|
826
|
+
} else if (data instanceof Uint8Array) {
|
|
827
|
+
message = deserialize(data);
|
|
828
|
+
} else {
|
|
829
|
+
try {
|
|
830
|
+
message = typeof data === "string" ? JSON.parse(data) : data;
|
|
831
|
+
} catch (e) {
|
|
832
|
+
logger.error({ err: e }, "Failed to parse message from ConnectionProvider");
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
this.handleServerMessage(message);
|
|
837
|
+
});
|
|
838
|
+
this.connectionProvider.on("partitionMapUpdated", () => {
|
|
839
|
+
logger.debug("Partition map updated");
|
|
840
|
+
});
|
|
841
|
+
this.connectionProvider.on("error", (error) => {
|
|
842
|
+
logger.error({ err: error }, "ConnectionProvider error");
|
|
843
|
+
});
|
|
844
|
+
this.connectionProvider.connect().catch((err) => {
|
|
845
|
+
logger.error({ err }, "Failed to connect via ConnectionProvider");
|
|
846
|
+
this.stateMachine.transition("DISCONNECTED" /* DISCONNECTED */);
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Initialize connection using direct WebSocket (legacy single-server mode).
|
|
851
|
+
*/
|
|
306
852
|
initConnection() {
|
|
307
853
|
this.stateMachine.transition("CONNECTING" /* CONNECTING */);
|
|
308
854
|
this.websocket = new WebSocket(this.serverUrl);
|
|
@@ -378,6 +924,40 @@ var SyncEngine = class {
|
|
|
378
924
|
resetBackoff() {
|
|
379
925
|
this.backoffAttempt = 0;
|
|
380
926
|
}
|
|
927
|
+
/**
|
|
928
|
+
* Send a message through the current connection.
|
|
929
|
+
* Uses connectionProvider if in cluster mode, otherwise uses direct websocket.
|
|
930
|
+
* @param message Message object to serialize and send
|
|
931
|
+
* @param key Optional key for routing (cluster mode only)
|
|
932
|
+
* @returns true if message was sent, false otherwise
|
|
933
|
+
*/
|
|
934
|
+
sendMessage(message, key) {
|
|
935
|
+
const data = serialize(message);
|
|
936
|
+
if (this.useConnectionProvider) {
|
|
937
|
+
try {
|
|
938
|
+
this.connectionProvider.send(data, key);
|
|
939
|
+
return true;
|
|
940
|
+
} catch (err) {
|
|
941
|
+
logger.warn({ err }, "Failed to send via ConnectionProvider");
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
944
|
+
} else {
|
|
945
|
+
if (this.websocket?.readyState === WebSocket.OPEN) {
|
|
946
|
+
this.websocket.send(data);
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
return false;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Check if we can send messages (connection is ready).
|
|
954
|
+
*/
|
|
955
|
+
canSend() {
|
|
956
|
+
if (this.useConnectionProvider) {
|
|
957
|
+
return this.connectionProvider.isConnected();
|
|
958
|
+
}
|
|
959
|
+
return this.websocket?.readyState === WebSocket.OPEN;
|
|
960
|
+
}
|
|
381
961
|
async loadOpLog() {
|
|
382
962
|
const storedTimestamp = await this.storageAdapter.getMeta("lastSyncTimestamp");
|
|
383
963
|
if (storedTimestamp) {
|
|
@@ -424,36 +1004,34 @@ var SyncEngine = class {
|
|
|
424
1004
|
const pending = this.opLog.filter((op) => !op.synced);
|
|
425
1005
|
if (pending.length === 0) return;
|
|
426
1006
|
logger.info({ count: pending.length }, "Syncing pending operations");
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}));
|
|
434
|
-
}
|
|
1007
|
+
this.sendMessage({
|
|
1008
|
+
type: "OP_BATCH",
|
|
1009
|
+
payload: {
|
|
1010
|
+
ops: pending
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
435
1013
|
}
|
|
436
1014
|
startMerkleSync() {
|
|
437
1015
|
for (const [mapName, map] of this.maps) {
|
|
438
1016
|
if (map instanceof LWWMap) {
|
|
439
1017
|
logger.info({ mapName }, "Starting Merkle sync for LWWMap");
|
|
440
|
-
this.
|
|
1018
|
+
this.sendMessage({
|
|
441
1019
|
type: "SYNC_INIT",
|
|
442
1020
|
mapName,
|
|
443
1021
|
lastSyncTimestamp: this.lastSyncTimestamp
|
|
444
|
-
})
|
|
1022
|
+
});
|
|
445
1023
|
} else if (map instanceof ORMap) {
|
|
446
1024
|
logger.info({ mapName }, "Starting Merkle sync for ORMap");
|
|
447
1025
|
const tree = map.getMerkleTree();
|
|
448
1026
|
const rootHash = tree.getRootHash();
|
|
449
1027
|
const bucketHashes = tree.getBuckets("");
|
|
450
|
-
this.
|
|
1028
|
+
this.sendMessage({
|
|
451
1029
|
type: "ORMAP_SYNC_INIT",
|
|
452
1030
|
mapName,
|
|
453
1031
|
rootHash,
|
|
454
1032
|
bucketHashes,
|
|
455
1033
|
lastSyncTimestamp: this.lastSyncTimestamp
|
|
456
|
-
})
|
|
1034
|
+
});
|
|
457
1035
|
}
|
|
458
1036
|
}
|
|
459
1037
|
}
|
|
@@ -494,10 +1072,10 @@ var SyncEngine = class {
|
|
|
494
1072
|
}
|
|
495
1073
|
const token = this.authToken;
|
|
496
1074
|
if (!token) return;
|
|
497
|
-
this.
|
|
1075
|
+
this.sendMessage({
|
|
498
1076
|
type: "AUTH",
|
|
499
1077
|
token
|
|
500
|
-
})
|
|
1078
|
+
});
|
|
501
1079
|
}
|
|
502
1080
|
subscribeToQuery(query) {
|
|
503
1081
|
this.queries.set(query.id, query);
|
|
@@ -514,27 +1092,27 @@ var SyncEngine = class {
|
|
|
514
1092
|
unsubscribeFromTopic(topic) {
|
|
515
1093
|
this.topics.delete(topic);
|
|
516
1094
|
if (this.isAuthenticated()) {
|
|
517
|
-
this.
|
|
1095
|
+
this.sendMessage({
|
|
518
1096
|
type: "TOPIC_UNSUB",
|
|
519
1097
|
payload: { topic }
|
|
520
|
-
})
|
|
1098
|
+
});
|
|
521
1099
|
}
|
|
522
1100
|
}
|
|
523
1101
|
publishTopic(topic, data) {
|
|
524
1102
|
if (this.isAuthenticated()) {
|
|
525
|
-
this.
|
|
1103
|
+
this.sendMessage({
|
|
526
1104
|
type: "TOPIC_PUB",
|
|
527
1105
|
payload: { topic, data }
|
|
528
|
-
})
|
|
1106
|
+
});
|
|
529
1107
|
} else {
|
|
530
1108
|
logger.warn({ topic }, "Dropped topic publish (offline)");
|
|
531
1109
|
}
|
|
532
1110
|
}
|
|
533
1111
|
sendTopicSubscription(topic) {
|
|
534
|
-
this.
|
|
1112
|
+
this.sendMessage({
|
|
535
1113
|
type: "TOPIC_SUB",
|
|
536
1114
|
payload: { topic }
|
|
537
|
-
})
|
|
1115
|
+
});
|
|
538
1116
|
}
|
|
539
1117
|
/**
|
|
540
1118
|
* Executes a query against local storage immediately
|
|
@@ -571,21 +1149,21 @@ var SyncEngine = class {
|
|
|
571
1149
|
unsubscribeFromQuery(queryId) {
|
|
572
1150
|
this.queries.delete(queryId);
|
|
573
1151
|
if (this.isAuthenticated()) {
|
|
574
|
-
this.
|
|
1152
|
+
this.sendMessage({
|
|
575
1153
|
type: "QUERY_UNSUB",
|
|
576
1154
|
payload: { queryId }
|
|
577
|
-
})
|
|
1155
|
+
});
|
|
578
1156
|
}
|
|
579
1157
|
}
|
|
580
1158
|
sendQuerySubscription(query) {
|
|
581
|
-
this.
|
|
1159
|
+
this.sendMessage({
|
|
582
1160
|
type: "QUERY_SUB",
|
|
583
1161
|
payload: {
|
|
584
1162
|
queryId: query.id,
|
|
585
1163
|
mapName: query.getMapName(),
|
|
586
1164
|
query: query.getFilter()
|
|
587
1165
|
}
|
|
588
|
-
})
|
|
1166
|
+
});
|
|
589
1167
|
}
|
|
590
1168
|
requestLock(name, requestId, ttl) {
|
|
591
1169
|
if (!this.isAuthenticated()) {
|
|
@@ -600,10 +1178,15 @@ var SyncEngine = class {
|
|
|
600
1178
|
}, 3e4);
|
|
601
1179
|
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
602
1180
|
try {
|
|
603
|
-
this.
|
|
1181
|
+
const sent = this.sendMessage({
|
|
604
1182
|
type: "LOCK_REQUEST",
|
|
605
1183
|
payload: { requestId, name, ttl }
|
|
606
|
-
})
|
|
1184
|
+
});
|
|
1185
|
+
if (!sent) {
|
|
1186
|
+
clearTimeout(timer);
|
|
1187
|
+
this.pendingLockRequests.delete(requestId);
|
|
1188
|
+
reject(new Error("Failed to send lock request"));
|
|
1189
|
+
}
|
|
607
1190
|
} catch (e) {
|
|
608
1191
|
clearTimeout(timer);
|
|
609
1192
|
this.pendingLockRequests.delete(requestId);
|
|
@@ -622,10 +1205,15 @@ var SyncEngine = class {
|
|
|
622
1205
|
}, 5e3);
|
|
623
1206
|
this.pendingLockRequests.set(requestId, { resolve, reject, timer });
|
|
624
1207
|
try {
|
|
625
|
-
this.
|
|
1208
|
+
const sent = this.sendMessage({
|
|
626
1209
|
type: "LOCK_RELEASE",
|
|
627
1210
|
payload: { requestId, name, fencingToken }
|
|
628
|
-
})
|
|
1211
|
+
});
|
|
1212
|
+
if (!sent) {
|
|
1213
|
+
clearTimeout(timer);
|
|
1214
|
+
this.pendingLockRequests.delete(requestId);
|
|
1215
|
+
resolve(false);
|
|
1216
|
+
}
|
|
629
1217
|
} catch (e) {
|
|
630
1218
|
clearTimeout(timer);
|
|
631
1219
|
this.pendingLockRequests.delete(requestId);
|
|
@@ -634,6 +1222,7 @@ var SyncEngine = class {
|
|
|
634
1222
|
});
|
|
635
1223
|
}
|
|
636
1224
|
async handleServerMessage(message) {
|
|
1225
|
+
this.emitMessage(message);
|
|
637
1226
|
switch (message.type) {
|
|
638
1227
|
case "BATCH": {
|
|
639
1228
|
const batchData = message.data;
|
|
@@ -804,11 +1393,11 @@ var SyncEngine = class {
|
|
|
804
1393
|
const { mapName } = message.payload;
|
|
805
1394
|
logger.warn({ mapName }, "Sync Reset Required due to GC Age");
|
|
806
1395
|
await this.resetMap(mapName);
|
|
807
|
-
this.
|
|
1396
|
+
this.sendMessage({
|
|
808
1397
|
type: "SYNC_INIT",
|
|
809
1398
|
mapName,
|
|
810
1399
|
lastSyncTimestamp: 0
|
|
811
|
-
})
|
|
1400
|
+
});
|
|
812
1401
|
break;
|
|
813
1402
|
}
|
|
814
1403
|
case "SYNC_RESP_ROOT": {
|
|
@@ -818,10 +1407,10 @@ var SyncEngine = class {
|
|
|
818
1407
|
const localRootHash = map.getMerkleTree().getRootHash();
|
|
819
1408
|
if (localRootHash !== rootHash) {
|
|
820
1409
|
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "Root hash mismatch, requesting buckets");
|
|
821
|
-
this.
|
|
1410
|
+
this.sendMessage({
|
|
822
1411
|
type: "MERKLE_REQ_BUCKET",
|
|
823
1412
|
payload: { mapName, path: "" }
|
|
824
|
-
})
|
|
1413
|
+
});
|
|
825
1414
|
} else {
|
|
826
1415
|
logger.info({ mapName }, "Map is in sync");
|
|
827
1416
|
}
|
|
@@ -843,10 +1432,10 @@ var SyncEngine = class {
|
|
|
843
1432
|
const localHash = localBuckets[bucketKey] || 0;
|
|
844
1433
|
if (localHash !== remoteHash) {
|
|
845
1434
|
const newPath = path + bucketKey;
|
|
846
|
-
this.
|
|
1435
|
+
this.sendMessage({
|
|
847
1436
|
type: "MERKLE_REQ_BUCKET",
|
|
848
1437
|
payload: { mapName, path: newPath }
|
|
849
|
-
})
|
|
1438
|
+
});
|
|
850
1439
|
}
|
|
851
1440
|
}
|
|
852
1441
|
}
|
|
@@ -879,10 +1468,10 @@ var SyncEngine = class {
|
|
|
879
1468
|
const localRootHash = localTree.getRootHash();
|
|
880
1469
|
if (localRootHash !== rootHash) {
|
|
881
1470
|
logger.info({ mapName, localRootHash, remoteRootHash: rootHash }, "ORMap root hash mismatch, requesting buckets");
|
|
882
|
-
this.
|
|
1471
|
+
this.sendMessage({
|
|
883
1472
|
type: "ORMAP_MERKLE_REQ_BUCKET",
|
|
884
1473
|
payload: { mapName, path: "" }
|
|
885
|
-
})
|
|
1474
|
+
});
|
|
886
1475
|
} else {
|
|
887
1476
|
logger.info({ mapName }, "ORMap is in sync");
|
|
888
1477
|
}
|
|
@@ -904,10 +1493,10 @@ var SyncEngine = class {
|
|
|
904
1493
|
const localHash = localBuckets[bucketKey] || 0;
|
|
905
1494
|
if (localHash !== remoteHash) {
|
|
906
1495
|
const newPath = path + bucketKey;
|
|
907
|
-
this.
|
|
1496
|
+
this.sendMessage({
|
|
908
1497
|
type: "ORMAP_MERKLE_REQ_BUCKET",
|
|
909
1498
|
payload: { mapName, path: newPath }
|
|
910
|
-
})
|
|
1499
|
+
});
|
|
911
1500
|
}
|
|
912
1501
|
}
|
|
913
1502
|
for (const [bucketKey, localHash] of Object.entries(localBuckets)) {
|
|
@@ -960,6 +1549,51 @@ var SyncEngine = class {
|
|
|
960
1549
|
}
|
|
961
1550
|
break;
|
|
962
1551
|
}
|
|
1552
|
+
// ============ PN Counter Message Handlers (Phase 5.2) ============
|
|
1553
|
+
case "COUNTER_UPDATE": {
|
|
1554
|
+
const { name, state } = message.payload;
|
|
1555
|
+
logger.debug({ name }, "Received COUNTER_UPDATE");
|
|
1556
|
+
this.handleCounterUpdate(name, state);
|
|
1557
|
+
break;
|
|
1558
|
+
}
|
|
1559
|
+
case "COUNTER_RESPONSE": {
|
|
1560
|
+
const { name, state } = message.payload;
|
|
1561
|
+
logger.debug({ name }, "Received COUNTER_RESPONSE");
|
|
1562
|
+
this.handleCounterUpdate(name, state);
|
|
1563
|
+
break;
|
|
1564
|
+
}
|
|
1565
|
+
// ============ Entry Processor Message Handlers (Phase 5.03) ============
|
|
1566
|
+
case "ENTRY_PROCESS_RESPONSE": {
|
|
1567
|
+
logger.debug({ requestId: message.requestId, success: message.success }, "Received ENTRY_PROCESS_RESPONSE");
|
|
1568
|
+
this.handleEntryProcessResponse(message);
|
|
1569
|
+
break;
|
|
1570
|
+
}
|
|
1571
|
+
case "ENTRY_PROCESS_BATCH_RESPONSE": {
|
|
1572
|
+
logger.debug({ requestId: message.requestId }, "Received ENTRY_PROCESS_BATCH_RESPONSE");
|
|
1573
|
+
this.handleEntryProcessBatchResponse(message);
|
|
1574
|
+
break;
|
|
1575
|
+
}
|
|
1576
|
+
// ============ Conflict Resolver Message Handlers (Phase 5.05) ============
|
|
1577
|
+
case "REGISTER_RESOLVER_RESPONSE": {
|
|
1578
|
+
logger.debug({ requestId: message.requestId, success: message.success }, "Received REGISTER_RESOLVER_RESPONSE");
|
|
1579
|
+
this.conflictResolverClient.handleRegisterResponse(message);
|
|
1580
|
+
break;
|
|
1581
|
+
}
|
|
1582
|
+
case "UNREGISTER_RESOLVER_RESPONSE": {
|
|
1583
|
+
logger.debug({ requestId: message.requestId, success: message.success }, "Received UNREGISTER_RESOLVER_RESPONSE");
|
|
1584
|
+
this.conflictResolverClient.handleUnregisterResponse(message);
|
|
1585
|
+
break;
|
|
1586
|
+
}
|
|
1587
|
+
case "LIST_RESOLVERS_RESPONSE": {
|
|
1588
|
+
logger.debug({ requestId: message.requestId }, "Received LIST_RESOLVERS_RESPONSE");
|
|
1589
|
+
this.conflictResolverClient.handleListResponse(message);
|
|
1590
|
+
break;
|
|
1591
|
+
}
|
|
1592
|
+
case "MERGE_REJECTED": {
|
|
1593
|
+
logger.debug({ mapName: message.mapName, key: message.key, reason: message.reason }, "Received MERGE_REJECTED");
|
|
1594
|
+
this.conflictResolverClient.handleMergeRejected(message);
|
|
1595
|
+
break;
|
|
1596
|
+
}
|
|
963
1597
|
}
|
|
964
1598
|
if (message.timestamp) {
|
|
965
1599
|
this.hlc.update(message.timestamp);
|
|
@@ -998,7 +1632,11 @@ var SyncEngine = class {
|
|
|
998
1632
|
clearTimeout(this.reconnectTimer);
|
|
999
1633
|
this.reconnectTimer = null;
|
|
1000
1634
|
}
|
|
1001
|
-
if (this.
|
|
1635
|
+
if (this.useConnectionProvider) {
|
|
1636
|
+
this.connectionProvider.close().catch((err) => {
|
|
1637
|
+
logger.error({ err }, "Error closing ConnectionProvider");
|
|
1638
|
+
});
|
|
1639
|
+
} else if (this.websocket) {
|
|
1002
1640
|
this.websocket.onclose = null;
|
|
1003
1641
|
this.websocket.close();
|
|
1004
1642
|
this.websocket = null;
|
|
@@ -1015,7 +1653,100 @@ var SyncEngine = class {
|
|
|
1015
1653
|
this.close();
|
|
1016
1654
|
this.stateMachine.reset();
|
|
1017
1655
|
this.resetBackoff();
|
|
1018
|
-
this.
|
|
1656
|
+
if (this.useConnectionProvider) {
|
|
1657
|
+
this.initConnectionProvider();
|
|
1658
|
+
} else {
|
|
1659
|
+
this.initConnection();
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
// ============================================
|
|
1663
|
+
// Failover Support Methods (Phase 4.5 Task 05)
|
|
1664
|
+
// ============================================
|
|
1665
|
+
/**
|
|
1666
|
+
* Wait for a partition map update from the connection provider.
|
|
1667
|
+
* Used when an operation fails with NOT_OWNER error and needs
|
|
1668
|
+
* to wait for an updated partition map before retrying.
|
|
1669
|
+
*
|
|
1670
|
+
* @param timeoutMs - Maximum time to wait (default: 5000ms)
|
|
1671
|
+
* @returns Promise that resolves when partition map is updated or times out
|
|
1672
|
+
*/
|
|
1673
|
+
waitForPartitionMapUpdate(timeoutMs = 5e3) {
|
|
1674
|
+
return new Promise((resolve) => {
|
|
1675
|
+
const timeout = setTimeout(resolve, timeoutMs);
|
|
1676
|
+
const handler2 = () => {
|
|
1677
|
+
clearTimeout(timeout);
|
|
1678
|
+
this.connectionProvider.off("partitionMapUpdated", handler2);
|
|
1679
|
+
resolve();
|
|
1680
|
+
};
|
|
1681
|
+
this.connectionProvider.on("partitionMapUpdated", handler2);
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Wait for the connection to be available.
|
|
1686
|
+
* Used when an operation fails due to connection issues and needs
|
|
1687
|
+
* to wait for reconnection before retrying.
|
|
1688
|
+
*
|
|
1689
|
+
* @param timeoutMs - Maximum time to wait (default: 10000ms)
|
|
1690
|
+
* @returns Promise that resolves when connected or rejects on timeout
|
|
1691
|
+
*/
|
|
1692
|
+
waitForConnection(timeoutMs = 1e4) {
|
|
1693
|
+
return new Promise((resolve, reject) => {
|
|
1694
|
+
if (this.connectionProvider.isConnected()) {
|
|
1695
|
+
resolve();
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
const timeout = setTimeout(() => {
|
|
1699
|
+
this.connectionProvider.off("connected", handler2);
|
|
1700
|
+
reject(new Error("Connection timeout waiting for reconnection"));
|
|
1701
|
+
}, timeoutMs);
|
|
1702
|
+
const handler2 = () => {
|
|
1703
|
+
clearTimeout(timeout);
|
|
1704
|
+
this.connectionProvider.off("connected", handler2);
|
|
1705
|
+
resolve();
|
|
1706
|
+
};
|
|
1707
|
+
this.connectionProvider.on("connected", handler2);
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* Wait for a specific sync state.
|
|
1712
|
+
* Useful for waiting until fully connected and synced.
|
|
1713
|
+
*
|
|
1714
|
+
* @param targetState - The state to wait for
|
|
1715
|
+
* @param timeoutMs - Maximum time to wait (default: 30000ms)
|
|
1716
|
+
* @returns Promise that resolves when state is reached or rejects on timeout
|
|
1717
|
+
*/
|
|
1718
|
+
waitForState(targetState, timeoutMs = 3e4) {
|
|
1719
|
+
return new Promise((resolve, reject) => {
|
|
1720
|
+
if (this.stateMachine.getState() === targetState) {
|
|
1721
|
+
resolve();
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
const timeout = setTimeout(() => {
|
|
1725
|
+
unsubscribe();
|
|
1726
|
+
reject(new Error(`Timeout waiting for state ${targetState}`));
|
|
1727
|
+
}, timeoutMs);
|
|
1728
|
+
const unsubscribe = this.stateMachine.onStateChange((event) => {
|
|
1729
|
+
if (event.to === targetState) {
|
|
1730
|
+
clearTimeout(timeout);
|
|
1731
|
+
unsubscribe();
|
|
1732
|
+
resolve();
|
|
1733
|
+
}
|
|
1734
|
+
});
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* Check if the connection provider is connected.
|
|
1739
|
+
* Convenience method for failover logic.
|
|
1740
|
+
*/
|
|
1741
|
+
isProviderConnected() {
|
|
1742
|
+
return this.connectionProvider.isConnected();
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Get the connection provider for direct access.
|
|
1746
|
+
* Use with caution - prefer using SyncEngine methods.
|
|
1747
|
+
*/
|
|
1748
|
+
getConnectionProvider() {
|
|
1749
|
+
return this.connectionProvider;
|
|
1019
1750
|
}
|
|
1020
1751
|
async resetMap(mapName) {
|
|
1021
1752
|
const map = this.maps.get(mapName);
|
|
@@ -1063,12 +1794,12 @@ var SyncEngine = class {
|
|
|
1063
1794
|
* Sends a PING message to the server.
|
|
1064
1795
|
*/
|
|
1065
1796
|
sendPing() {
|
|
1066
|
-
if (this.
|
|
1797
|
+
if (this.canSend()) {
|
|
1067
1798
|
const pingMessage = {
|
|
1068
1799
|
type: "PING",
|
|
1069
1800
|
timestamp: Date.now()
|
|
1070
1801
|
};
|
|
1071
|
-
this.
|
|
1802
|
+
this.sendMessage(pingMessage);
|
|
1072
1803
|
}
|
|
1073
1804
|
}
|
|
1074
1805
|
/**
|
|
@@ -1147,13 +1878,13 @@ var SyncEngine = class {
|
|
|
1147
1878
|
}
|
|
1148
1879
|
}
|
|
1149
1880
|
if (entries.length > 0) {
|
|
1150
|
-
this.
|
|
1881
|
+
this.sendMessage({
|
|
1151
1882
|
type: "ORMAP_PUSH_DIFF",
|
|
1152
1883
|
payload: {
|
|
1153
1884
|
mapName,
|
|
1154
1885
|
entries
|
|
1155
1886
|
}
|
|
1156
|
-
})
|
|
1887
|
+
});
|
|
1157
1888
|
logger.debug({ mapName, keyCount: entries.length }, "Pushed ORMap diff to server");
|
|
1158
1889
|
}
|
|
1159
1890
|
}
|
|
@@ -1376,16 +2107,371 @@ var SyncEngine = class {
|
|
|
1376
2107
|
}
|
|
1377
2108
|
this.pendingWriteConcernPromises.clear();
|
|
1378
2109
|
}
|
|
2110
|
+
/**
|
|
2111
|
+
* Subscribe to counter updates from server.
|
|
2112
|
+
* @param name Counter name
|
|
2113
|
+
* @param listener Callback when counter state is updated
|
|
2114
|
+
* @returns Unsubscribe function
|
|
2115
|
+
*/
|
|
2116
|
+
onCounterUpdate(name, listener) {
|
|
2117
|
+
if (!this.counterUpdateListeners.has(name)) {
|
|
2118
|
+
this.counterUpdateListeners.set(name, /* @__PURE__ */ new Set());
|
|
2119
|
+
}
|
|
2120
|
+
this.counterUpdateListeners.get(name).add(listener);
|
|
2121
|
+
return () => {
|
|
2122
|
+
this.counterUpdateListeners.get(name)?.delete(listener);
|
|
2123
|
+
if (this.counterUpdateListeners.get(name)?.size === 0) {
|
|
2124
|
+
this.counterUpdateListeners.delete(name);
|
|
2125
|
+
}
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
/**
|
|
2129
|
+
* Request initial counter state from server.
|
|
2130
|
+
* @param name Counter name
|
|
2131
|
+
*/
|
|
2132
|
+
requestCounter(name) {
|
|
2133
|
+
if (this.isAuthenticated()) {
|
|
2134
|
+
this.sendMessage({
|
|
2135
|
+
type: "COUNTER_REQUEST",
|
|
2136
|
+
payload: { name }
|
|
2137
|
+
});
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Sync local counter state to server.
|
|
2142
|
+
* @param name Counter name
|
|
2143
|
+
* @param state Counter state to sync
|
|
2144
|
+
*/
|
|
2145
|
+
syncCounter(name, state) {
|
|
2146
|
+
if (this.isAuthenticated()) {
|
|
2147
|
+
const stateObj = {
|
|
2148
|
+
positive: Object.fromEntries(state.positive),
|
|
2149
|
+
negative: Object.fromEntries(state.negative)
|
|
2150
|
+
};
|
|
2151
|
+
this.sendMessage({
|
|
2152
|
+
type: "COUNTER_SYNC",
|
|
2153
|
+
payload: {
|
|
2154
|
+
name,
|
|
2155
|
+
state: stateObj
|
|
2156
|
+
}
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Handle incoming counter update from server.
|
|
2162
|
+
* Called by handleServerMessage for COUNTER_UPDATE messages.
|
|
2163
|
+
*/
|
|
2164
|
+
handleCounterUpdate(name, stateObj) {
|
|
2165
|
+
const state = {
|
|
2166
|
+
positive: new Map(Object.entries(stateObj.positive)),
|
|
2167
|
+
negative: new Map(Object.entries(stateObj.negative))
|
|
2168
|
+
};
|
|
2169
|
+
const listeners = this.counterUpdateListeners.get(name);
|
|
2170
|
+
if (listeners) {
|
|
2171
|
+
for (const listener of listeners) {
|
|
2172
|
+
try {
|
|
2173
|
+
listener(state);
|
|
2174
|
+
} catch (e) {
|
|
2175
|
+
logger.error({ err: e, counterName: name }, "Counter update listener error");
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2181
|
+
* Execute an entry processor on a single key atomically.
|
|
2182
|
+
*
|
|
2183
|
+
* @param mapName Name of the map
|
|
2184
|
+
* @param key Key to process
|
|
2185
|
+
* @param processor Processor definition
|
|
2186
|
+
* @returns Promise resolving to the processor result
|
|
2187
|
+
*/
|
|
2188
|
+
async executeOnKey(mapName, key, processor) {
|
|
2189
|
+
if (!this.isAuthenticated()) {
|
|
2190
|
+
return {
|
|
2191
|
+
success: false,
|
|
2192
|
+
error: "Not connected to server"
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
const requestId = crypto.randomUUID();
|
|
2196
|
+
return new Promise((resolve, reject) => {
|
|
2197
|
+
const timeout = setTimeout(() => {
|
|
2198
|
+
this.pendingProcessorRequests.delete(requestId);
|
|
2199
|
+
reject(new Error("Entry processor request timed out"));
|
|
2200
|
+
}, _SyncEngine.PROCESSOR_TIMEOUT);
|
|
2201
|
+
this.pendingProcessorRequests.set(requestId, {
|
|
2202
|
+
resolve: (result) => {
|
|
2203
|
+
clearTimeout(timeout);
|
|
2204
|
+
resolve(result);
|
|
2205
|
+
},
|
|
2206
|
+
reject,
|
|
2207
|
+
timeout
|
|
2208
|
+
});
|
|
2209
|
+
const sent = this.sendMessage({
|
|
2210
|
+
type: "ENTRY_PROCESS",
|
|
2211
|
+
requestId,
|
|
2212
|
+
mapName,
|
|
2213
|
+
key,
|
|
2214
|
+
processor: {
|
|
2215
|
+
name: processor.name,
|
|
2216
|
+
code: processor.code,
|
|
2217
|
+
args: processor.args
|
|
2218
|
+
}
|
|
2219
|
+
}, key);
|
|
2220
|
+
if (!sent) {
|
|
2221
|
+
this.pendingProcessorRequests.delete(requestId);
|
|
2222
|
+
clearTimeout(timeout);
|
|
2223
|
+
reject(new Error("Failed to send entry processor request"));
|
|
2224
|
+
}
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
/**
|
|
2228
|
+
* Execute an entry processor on multiple keys.
|
|
2229
|
+
*
|
|
2230
|
+
* @param mapName Name of the map
|
|
2231
|
+
* @param keys Keys to process
|
|
2232
|
+
* @param processor Processor definition
|
|
2233
|
+
* @returns Promise resolving to a map of key -> result
|
|
2234
|
+
*/
|
|
2235
|
+
async executeOnKeys(mapName, keys, processor) {
|
|
2236
|
+
if (!this.isAuthenticated()) {
|
|
2237
|
+
const results = /* @__PURE__ */ new Map();
|
|
2238
|
+
const error = {
|
|
2239
|
+
success: false,
|
|
2240
|
+
error: "Not connected to server"
|
|
2241
|
+
};
|
|
2242
|
+
for (const key of keys) {
|
|
2243
|
+
results.set(key, error);
|
|
2244
|
+
}
|
|
2245
|
+
return results;
|
|
2246
|
+
}
|
|
2247
|
+
const requestId = crypto.randomUUID();
|
|
2248
|
+
return new Promise((resolve, reject) => {
|
|
2249
|
+
const timeout = setTimeout(() => {
|
|
2250
|
+
this.pendingBatchProcessorRequests.delete(requestId);
|
|
2251
|
+
reject(new Error("Entry processor batch request timed out"));
|
|
2252
|
+
}, _SyncEngine.PROCESSOR_TIMEOUT);
|
|
2253
|
+
this.pendingBatchProcessorRequests.set(requestId, {
|
|
2254
|
+
resolve: (results) => {
|
|
2255
|
+
clearTimeout(timeout);
|
|
2256
|
+
resolve(results);
|
|
2257
|
+
},
|
|
2258
|
+
reject,
|
|
2259
|
+
timeout
|
|
2260
|
+
});
|
|
2261
|
+
const sent = this.sendMessage({
|
|
2262
|
+
type: "ENTRY_PROCESS_BATCH",
|
|
2263
|
+
requestId,
|
|
2264
|
+
mapName,
|
|
2265
|
+
keys,
|
|
2266
|
+
processor: {
|
|
2267
|
+
name: processor.name,
|
|
2268
|
+
code: processor.code,
|
|
2269
|
+
args: processor.args
|
|
2270
|
+
}
|
|
2271
|
+
});
|
|
2272
|
+
if (!sent) {
|
|
2273
|
+
this.pendingBatchProcessorRequests.delete(requestId);
|
|
2274
|
+
clearTimeout(timeout);
|
|
2275
|
+
reject(new Error("Failed to send entry processor batch request"));
|
|
2276
|
+
}
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* Handle entry processor response from server.
|
|
2281
|
+
* Called by handleServerMessage for ENTRY_PROCESS_RESPONSE messages.
|
|
2282
|
+
*/
|
|
2283
|
+
handleEntryProcessResponse(message) {
|
|
2284
|
+
const pending = this.pendingProcessorRequests.get(message.requestId);
|
|
2285
|
+
if (pending) {
|
|
2286
|
+
this.pendingProcessorRequests.delete(message.requestId);
|
|
2287
|
+
pending.resolve({
|
|
2288
|
+
success: message.success,
|
|
2289
|
+
result: message.result,
|
|
2290
|
+
newValue: message.newValue,
|
|
2291
|
+
error: message.error
|
|
2292
|
+
});
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
/**
|
|
2296
|
+
* Handle entry processor batch response from server.
|
|
2297
|
+
* Called by handleServerMessage for ENTRY_PROCESS_BATCH_RESPONSE messages.
|
|
2298
|
+
*/
|
|
2299
|
+
handleEntryProcessBatchResponse(message) {
|
|
2300
|
+
const pending = this.pendingBatchProcessorRequests.get(message.requestId);
|
|
2301
|
+
if (pending) {
|
|
2302
|
+
this.pendingBatchProcessorRequests.delete(message.requestId);
|
|
2303
|
+
const resultsMap = /* @__PURE__ */ new Map();
|
|
2304
|
+
for (const [key, result] of Object.entries(message.results)) {
|
|
2305
|
+
resultsMap.set(key, {
|
|
2306
|
+
success: result.success,
|
|
2307
|
+
result: result.result,
|
|
2308
|
+
newValue: result.newValue,
|
|
2309
|
+
error: result.error
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
pending.resolve(resultsMap);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
/**
|
|
2316
|
+
* Subscribe to all incoming messages.
|
|
2317
|
+
* Used by EventJournalReader to receive journal events.
|
|
2318
|
+
*
|
|
2319
|
+
* @param event Event type (currently only 'message')
|
|
2320
|
+
* @param handler Message handler
|
|
2321
|
+
*/
|
|
2322
|
+
on(event, handler2) {
|
|
2323
|
+
if (event === "message") {
|
|
2324
|
+
this.messageListeners.add(handler2);
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
/**
|
|
2328
|
+
* Unsubscribe from incoming messages.
|
|
2329
|
+
*
|
|
2330
|
+
* @param event Event type (currently only 'message')
|
|
2331
|
+
* @param handler Message handler to remove
|
|
2332
|
+
*/
|
|
2333
|
+
off(event, handler2) {
|
|
2334
|
+
if (event === "message") {
|
|
2335
|
+
this.messageListeners.delete(handler2);
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Send a message to the server.
|
|
2340
|
+
* Public method for EventJournalReader and other components.
|
|
2341
|
+
*
|
|
2342
|
+
* @param message Message object to send
|
|
2343
|
+
*/
|
|
2344
|
+
send(message) {
|
|
2345
|
+
this.sendMessage(message);
|
|
2346
|
+
}
|
|
2347
|
+
/**
|
|
2348
|
+
* Emit message to all listeners.
|
|
2349
|
+
* Called internally when a message is received.
|
|
2350
|
+
*/
|
|
2351
|
+
emitMessage(message) {
|
|
2352
|
+
for (const listener of this.messageListeners) {
|
|
2353
|
+
try {
|
|
2354
|
+
listener(message);
|
|
2355
|
+
} catch (e) {
|
|
2356
|
+
logger.error({ err: e }, "Message listener error");
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
// ============================================
|
|
2361
|
+
// Conflict Resolver Client (Phase 5.05)
|
|
2362
|
+
// ============================================
|
|
2363
|
+
/**
|
|
2364
|
+
* Get the conflict resolver client for registering custom resolvers
|
|
2365
|
+
* and subscribing to merge rejection events.
|
|
2366
|
+
*/
|
|
2367
|
+
getConflictResolverClient() {
|
|
2368
|
+
return this.conflictResolverClient;
|
|
2369
|
+
}
|
|
1379
2370
|
};
|
|
2371
|
+
/** Default timeout for entry processor requests (ms) */
|
|
2372
|
+
_SyncEngine.PROCESSOR_TIMEOUT = 3e4;
|
|
2373
|
+
var SyncEngine = _SyncEngine;
|
|
1380
2374
|
|
|
1381
2375
|
// src/TopGunClient.ts
|
|
1382
2376
|
import { LWWMap as LWWMap2, ORMap as ORMap2 } from "@topgunbuild/core";
|
|
1383
2377
|
|
|
2378
|
+
// src/utils/deepEqual.ts
|
|
2379
|
+
function deepEqual(a, b) {
|
|
2380
|
+
if (a === b) return true;
|
|
2381
|
+
if (a == null || b == null) return a === b;
|
|
2382
|
+
if (typeof a !== typeof b) return false;
|
|
2383
|
+
if (typeof a !== "object") return a === b;
|
|
2384
|
+
if (Array.isArray(a)) {
|
|
2385
|
+
if (!Array.isArray(b)) return false;
|
|
2386
|
+
if (a.length !== b.length) return false;
|
|
2387
|
+
for (let i = 0; i < a.length; i++) {
|
|
2388
|
+
if (!deepEqual(a[i], b[i])) return false;
|
|
2389
|
+
}
|
|
2390
|
+
return true;
|
|
2391
|
+
}
|
|
2392
|
+
if (Array.isArray(b)) return false;
|
|
2393
|
+
const objA = a;
|
|
2394
|
+
const objB = b;
|
|
2395
|
+
const keysA = Object.keys(objA);
|
|
2396
|
+
const keysB = Object.keys(objB);
|
|
2397
|
+
if (keysA.length !== keysB.length) return false;
|
|
2398
|
+
for (const key of keysA) {
|
|
2399
|
+
if (!Object.prototype.hasOwnProperty.call(objB, key)) return false;
|
|
2400
|
+
if (!deepEqual(objA[key], objB[key])) return false;
|
|
2401
|
+
}
|
|
2402
|
+
return true;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// src/ChangeTracker.ts
|
|
2406
|
+
var ChangeTracker = class {
|
|
2407
|
+
constructor() {
|
|
2408
|
+
this.previousSnapshot = /* @__PURE__ */ new Map();
|
|
2409
|
+
}
|
|
2410
|
+
/**
|
|
2411
|
+
* Computes changes between previous and current state.
|
|
2412
|
+
* Updates internal snapshot after computation.
|
|
2413
|
+
*
|
|
2414
|
+
* @param current - Current state as a Map
|
|
2415
|
+
* @param timestamp - HLC timestamp for the changes
|
|
2416
|
+
* @returns Array of change events (may be empty if no changes)
|
|
2417
|
+
*/
|
|
2418
|
+
computeChanges(current, timestamp) {
|
|
2419
|
+
const changes = [];
|
|
2420
|
+
for (const [key, value] of current) {
|
|
2421
|
+
const previous = this.previousSnapshot.get(key);
|
|
2422
|
+
if (previous === void 0) {
|
|
2423
|
+
changes.push({ type: "add", key, value, timestamp });
|
|
2424
|
+
} else if (!deepEqual(previous, value)) {
|
|
2425
|
+
changes.push({
|
|
2426
|
+
type: "update",
|
|
2427
|
+
key,
|
|
2428
|
+
value,
|
|
2429
|
+
previousValue: previous,
|
|
2430
|
+
timestamp
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
for (const [key, value] of this.previousSnapshot) {
|
|
2435
|
+
if (!current.has(key)) {
|
|
2436
|
+
changes.push({
|
|
2437
|
+
type: "remove",
|
|
2438
|
+
key,
|
|
2439
|
+
previousValue: value,
|
|
2440
|
+
timestamp
|
|
2441
|
+
});
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
this.previousSnapshot = new Map(
|
|
2445
|
+
Array.from(current.entries()).map(([k, v]) => [
|
|
2446
|
+
k,
|
|
2447
|
+
typeof v === "object" && v !== null ? { ...v } : v
|
|
2448
|
+
])
|
|
2449
|
+
);
|
|
2450
|
+
return changes;
|
|
2451
|
+
}
|
|
2452
|
+
/**
|
|
2453
|
+
* Reset tracker (e.g., on query change or reconnect)
|
|
2454
|
+
*/
|
|
2455
|
+
reset() {
|
|
2456
|
+
this.previousSnapshot.clear();
|
|
2457
|
+
}
|
|
2458
|
+
/**
|
|
2459
|
+
* Get current snapshot size for debugging/metrics
|
|
2460
|
+
*/
|
|
2461
|
+
get size() {
|
|
2462
|
+
return this.previousSnapshot.size;
|
|
2463
|
+
}
|
|
2464
|
+
};
|
|
2465
|
+
|
|
1384
2466
|
// src/QueryHandle.ts
|
|
1385
2467
|
var QueryHandle = class {
|
|
1386
2468
|
constructor(syncEngine, mapName, filter = {}) {
|
|
1387
2469
|
this.listeners = /* @__PURE__ */ new Set();
|
|
1388
2470
|
this.currentResults = /* @__PURE__ */ new Map();
|
|
2471
|
+
// Change tracking (Phase 5.1)
|
|
2472
|
+
this.changeTracker = new ChangeTracker();
|
|
2473
|
+
this.pendingChanges = [];
|
|
2474
|
+
this.changeListeners = /* @__PURE__ */ new Set();
|
|
1389
2475
|
// Track if we've received authoritative server response
|
|
1390
2476
|
this.hasReceivedServerData = false;
|
|
1391
2477
|
this.id = crypto.randomUUID();
|
|
@@ -1428,14 +2514,15 @@ var QueryHandle = class {
|
|
|
1428
2514
|
* - Works with any async storage adapter (PostgreSQL, SQLite, Redis, etc.)
|
|
1429
2515
|
*/
|
|
1430
2516
|
onResult(items, source = "server") {
|
|
1431
|
-
|
|
2517
|
+
logger.debug({
|
|
2518
|
+
mapName: this.mapName,
|
|
2519
|
+
itemCount: items.length,
|
|
1432
2520
|
source,
|
|
1433
2521
|
currentResultsCount: this.currentResults.size,
|
|
1434
|
-
newItemKeys: items.map((i) => i.key),
|
|
1435
2522
|
hasReceivedServerData: this.hasReceivedServerData
|
|
1436
|
-
});
|
|
2523
|
+
}, "QueryHandle onResult");
|
|
1437
2524
|
if (source === "server" && items.length === 0 && !this.hasReceivedServerData) {
|
|
1438
|
-
|
|
2525
|
+
logger.debug({ mapName: this.mapName }, "QueryHandle ignoring empty server response - waiting for authoritative data");
|
|
1439
2526
|
return;
|
|
1440
2527
|
}
|
|
1441
2528
|
if (source === "server" && items.length > 0) {
|
|
@@ -1450,12 +2537,20 @@ var QueryHandle = class {
|
|
|
1450
2537
|
}
|
|
1451
2538
|
}
|
|
1452
2539
|
if (removedKeys.length > 0) {
|
|
1453
|
-
|
|
2540
|
+
logger.debug({
|
|
2541
|
+
mapName: this.mapName,
|
|
2542
|
+
removedCount: removedKeys.length,
|
|
2543
|
+
removedKeys
|
|
2544
|
+
}, "QueryHandle removed keys");
|
|
1454
2545
|
}
|
|
1455
2546
|
for (const item of items) {
|
|
1456
2547
|
this.currentResults.set(item.key, item.value);
|
|
1457
2548
|
}
|
|
1458
|
-
|
|
2549
|
+
logger.debug({
|
|
2550
|
+
mapName: this.mapName,
|
|
2551
|
+
resultCount: this.currentResults.size
|
|
2552
|
+
}, "QueryHandle after merge");
|
|
2553
|
+
this.computeAndNotifyChanges(Date.now());
|
|
1459
2554
|
this.notify();
|
|
1460
2555
|
}
|
|
1461
2556
|
/**
|
|
@@ -1467,8 +2562,80 @@ var QueryHandle = class {
|
|
|
1467
2562
|
} else {
|
|
1468
2563
|
this.currentResults.set(key, value);
|
|
1469
2564
|
}
|
|
2565
|
+
this.computeAndNotifyChanges(Date.now());
|
|
1470
2566
|
this.notify();
|
|
1471
2567
|
}
|
|
2568
|
+
/**
|
|
2569
|
+
* Subscribe to change events (Phase 5.1).
|
|
2570
|
+
* Returns an unsubscribe function.
|
|
2571
|
+
*
|
|
2572
|
+
* @example
|
|
2573
|
+
* ```typescript
|
|
2574
|
+
* const unsubscribe = handle.onChanges((changes) => {
|
|
2575
|
+
* for (const change of changes) {
|
|
2576
|
+
* if (change.type === 'add') {
|
|
2577
|
+
* console.log('Added:', change.key, change.value);
|
|
2578
|
+
* }
|
|
2579
|
+
* }
|
|
2580
|
+
* });
|
|
2581
|
+
* ```
|
|
2582
|
+
*/
|
|
2583
|
+
onChanges(listener) {
|
|
2584
|
+
this.changeListeners.add(listener);
|
|
2585
|
+
return () => this.changeListeners.delete(listener);
|
|
2586
|
+
}
|
|
2587
|
+
/**
|
|
2588
|
+
* Get and clear pending changes (Phase 5.1).
|
|
2589
|
+
* Call this to retrieve all changes since the last consume.
|
|
2590
|
+
*/
|
|
2591
|
+
consumeChanges() {
|
|
2592
|
+
const changes = [...this.pendingChanges];
|
|
2593
|
+
this.pendingChanges = [];
|
|
2594
|
+
return changes;
|
|
2595
|
+
}
|
|
2596
|
+
/**
|
|
2597
|
+
* Get last change without consuming (Phase 5.1).
|
|
2598
|
+
* Returns null if no pending changes.
|
|
2599
|
+
*/
|
|
2600
|
+
getLastChange() {
|
|
2601
|
+
return this.pendingChanges.length > 0 ? this.pendingChanges[this.pendingChanges.length - 1] : null;
|
|
2602
|
+
}
|
|
2603
|
+
/**
|
|
2604
|
+
* Get all pending changes without consuming (Phase 5.1).
|
|
2605
|
+
*/
|
|
2606
|
+
getPendingChanges() {
|
|
2607
|
+
return [...this.pendingChanges];
|
|
2608
|
+
}
|
|
2609
|
+
/**
|
|
2610
|
+
* Clear all pending changes (Phase 5.1).
|
|
2611
|
+
*/
|
|
2612
|
+
clearChanges() {
|
|
2613
|
+
this.pendingChanges = [];
|
|
2614
|
+
}
|
|
2615
|
+
/**
|
|
2616
|
+
* Reset change tracker (Phase 5.1).
|
|
2617
|
+
* Use when query filter changes or on reconnect.
|
|
2618
|
+
*/
|
|
2619
|
+
resetChangeTracker() {
|
|
2620
|
+
this.changeTracker.reset();
|
|
2621
|
+
this.pendingChanges = [];
|
|
2622
|
+
}
|
|
2623
|
+
computeAndNotifyChanges(timestamp) {
|
|
2624
|
+
const changes = this.changeTracker.computeChanges(this.currentResults, timestamp);
|
|
2625
|
+
if (changes.length > 0) {
|
|
2626
|
+
this.pendingChanges.push(...changes);
|
|
2627
|
+
this.notifyChangeListeners(changes);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
notifyChangeListeners(changes) {
|
|
2631
|
+
for (const listener of this.changeListeners) {
|
|
2632
|
+
try {
|
|
2633
|
+
listener(changes);
|
|
2634
|
+
} catch (e) {
|
|
2635
|
+
logger.error({ err: e }, "QueryHandle change listener error");
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
1472
2639
|
notify() {
|
|
1473
2640
|
const results = this.getSortedResults();
|
|
1474
2641
|
for (const listener of this.listeners) {
|
|
@@ -1487,114 +2654,1877 @@ var QueryHandle = class {
|
|
|
1487
2654
|
if (valA < valB) return direction === "asc" ? -1 : 1;
|
|
1488
2655
|
if (valA > valB) return direction === "asc" ? 1 : -1;
|
|
1489
2656
|
}
|
|
1490
|
-
return 0;
|
|
2657
|
+
return 0;
|
|
2658
|
+
});
|
|
2659
|
+
}
|
|
2660
|
+
return results;
|
|
2661
|
+
}
|
|
2662
|
+
getFilter() {
|
|
2663
|
+
return this.filter;
|
|
2664
|
+
}
|
|
2665
|
+
getMapName() {
|
|
2666
|
+
return this.mapName;
|
|
2667
|
+
}
|
|
2668
|
+
};
|
|
2669
|
+
|
|
2670
|
+
// src/DistributedLock.ts
|
|
2671
|
+
var DistributedLock = class {
|
|
2672
|
+
constructor(syncEngine, name) {
|
|
2673
|
+
this.fencingToken = null;
|
|
2674
|
+
this._isLocked = false;
|
|
2675
|
+
this.syncEngine = syncEngine;
|
|
2676
|
+
this.name = name;
|
|
2677
|
+
}
|
|
2678
|
+
async lock(ttl = 1e4) {
|
|
2679
|
+
const requestId = crypto.randomUUID();
|
|
2680
|
+
try {
|
|
2681
|
+
const result = await this.syncEngine.requestLock(this.name, requestId, ttl);
|
|
2682
|
+
this.fencingToken = result.fencingToken;
|
|
2683
|
+
this._isLocked = true;
|
|
2684
|
+
return true;
|
|
2685
|
+
} catch (e) {
|
|
2686
|
+
return false;
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
async unlock() {
|
|
2690
|
+
if (!this._isLocked || this.fencingToken === null) return;
|
|
2691
|
+
const requestId = crypto.randomUUID();
|
|
2692
|
+
try {
|
|
2693
|
+
await this.syncEngine.releaseLock(this.name, requestId, this.fencingToken);
|
|
2694
|
+
} finally {
|
|
2695
|
+
this._isLocked = false;
|
|
2696
|
+
this.fencingToken = null;
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
isLocked() {
|
|
2700
|
+
return this._isLocked;
|
|
2701
|
+
}
|
|
2702
|
+
};
|
|
2703
|
+
|
|
2704
|
+
// src/TopicHandle.ts
|
|
2705
|
+
var TopicHandle = class {
|
|
2706
|
+
constructor(engine, topic) {
|
|
2707
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
2708
|
+
this.engine = engine;
|
|
2709
|
+
this.topic = topic;
|
|
2710
|
+
}
|
|
2711
|
+
get id() {
|
|
2712
|
+
return this.topic;
|
|
2713
|
+
}
|
|
2714
|
+
/**
|
|
2715
|
+
* Publish a message to the topic
|
|
2716
|
+
*/
|
|
2717
|
+
publish(data) {
|
|
2718
|
+
this.engine.publishTopic(this.topic, data);
|
|
2719
|
+
}
|
|
2720
|
+
/**
|
|
2721
|
+
* Subscribe to the topic
|
|
2722
|
+
*/
|
|
2723
|
+
subscribe(callback) {
|
|
2724
|
+
if (this.listeners.size === 0) {
|
|
2725
|
+
this.engine.subscribeToTopic(this.topic, this);
|
|
2726
|
+
}
|
|
2727
|
+
this.listeners.add(callback);
|
|
2728
|
+
return () => this.unsubscribe(callback);
|
|
2729
|
+
}
|
|
2730
|
+
unsubscribe(callback) {
|
|
2731
|
+
this.listeners.delete(callback);
|
|
2732
|
+
if (this.listeners.size === 0) {
|
|
2733
|
+
this.engine.unsubscribeFromTopic(this.topic);
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
/**
|
|
2737
|
+
* Called by SyncEngine when a message is received
|
|
2738
|
+
*/
|
|
2739
|
+
onMessage(data, context) {
|
|
2740
|
+
this.listeners.forEach((cb) => {
|
|
2741
|
+
try {
|
|
2742
|
+
cb(data, context);
|
|
2743
|
+
} catch (e) {
|
|
2744
|
+
console.error("Error in topic listener", e);
|
|
2745
|
+
}
|
|
2746
|
+
});
|
|
2747
|
+
}
|
|
2748
|
+
};
|
|
2749
|
+
|
|
2750
|
+
// src/PNCounterHandle.ts
|
|
2751
|
+
import { PNCounterImpl } from "@topgunbuild/core";
|
|
2752
|
+
var COUNTER_STORAGE_PREFIX = "__counter__:";
|
|
2753
|
+
var PNCounterHandle = class {
|
|
2754
|
+
constructor(name, nodeId, syncEngine, storageAdapter) {
|
|
2755
|
+
this.syncScheduled = false;
|
|
2756
|
+
this.persistScheduled = false;
|
|
2757
|
+
this.name = name;
|
|
2758
|
+
this.syncEngine = syncEngine;
|
|
2759
|
+
this.storageAdapter = storageAdapter;
|
|
2760
|
+
this.counter = new PNCounterImpl({ nodeId });
|
|
2761
|
+
this.restoreFromStorage();
|
|
2762
|
+
this.unsubscribeFromUpdates = this.syncEngine.onCounterUpdate(name, (state) => {
|
|
2763
|
+
this.counter.merge(state);
|
|
2764
|
+
this.schedulePersist();
|
|
2765
|
+
});
|
|
2766
|
+
this.syncEngine.requestCounter(name);
|
|
2767
|
+
logger.debug({ name, nodeId }, "PNCounterHandle created");
|
|
2768
|
+
}
|
|
2769
|
+
/**
|
|
2770
|
+
* Restore counter state from local storage.
|
|
2771
|
+
* Called during construction to recover offline state.
|
|
2772
|
+
*/
|
|
2773
|
+
async restoreFromStorage() {
|
|
2774
|
+
if (!this.storageAdapter) {
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
try {
|
|
2778
|
+
const storageKey = COUNTER_STORAGE_PREFIX + this.name;
|
|
2779
|
+
const stored = await this.storageAdapter.getMeta(storageKey);
|
|
2780
|
+
if (stored && typeof stored === "object" && "p" in stored && "n" in stored) {
|
|
2781
|
+
const state = PNCounterImpl.objectToState(stored);
|
|
2782
|
+
this.counter.merge(state);
|
|
2783
|
+
logger.debug({ name: this.name, value: this.counter.get() }, "PNCounter restored from storage");
|
|
2784
|
+
}
|
|
2785
|
+
} catch (err) {
|
|
2786
|
+
logger.error({ err, name: this.name }, "Failed to restore PNCounter from storage");
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
/**
|
|
2790
|
+
* Persist counter state to local storage.
|
|
2791
|
+
* Debounced to avoid excessive writes during rapid operations.
|
|
2792
|
+
*/
|
|
2793
|
+
schedulePersist() {
|
|
2794
|
+
if (!this.storageAdapter || this.persistScheduled) return;
|
|
2795
|
+
this.persistScheduled = true;
|
|
2796
|
+
setTimeout(() => {
|
|
2797
|
+
this.persistScheduled = false;
|
|
2798
|
+
this.persistToStorage();
|
|
2799
|
+
}, 100);
|
|
2800
|
+
}
|
|
2801
|
+
/**
|
|
2802
|
+
* Actually persist state to storage.
|
|
2803
|
+
*/
|
|
2804
|
+
async persistToStorage() {
|
|
2805
|
+
if (!this.storageAdapter) return;
|
|
2806
|
+
try {
|
|
2807
|
+
const storageKey = COUNTER_STORAGE_PREFIX + this.name;
|
|
2808
|
+
const stateObj = PNCounterImpl.stateToObject(this.counter.getState());
|
|
2809
|
+
await this.storageAdapter.setMeta(storageKey, stateObj);
|
|
2810
|
+
logger.debug({ name: this.name, value: this.counter.get() }, "PNCounter persisted to storage");
|
|
2811
|
+
} catch (err) {
|
|
2812
|
+
logger.error({ err, name: this.name }, "Failed to persist PNCounter to storage");
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
/**
|
|
2816
|
+
* Get current counter value.
|
|
2817
|
+
*/
|
|
2818
|
+
get() {
|
|
2819
|
+
return this.counter.get();
|
|
2820
|
+
}
|
|
2821
|
+
/**
|
|
2822
|
+
* Increment by 1 and return new value.
|
|
2823
|
+
*/
|
|
2824
|
+
increment() {
|
|
2825
|
+
const value = this.counter.increment();
|
|
2826
|
+
this.scheduleSync();
|
|
2827
|
+
this.schedulePersist();
|
|
2828
|
+
return value;
|
|
2829
|
+
}
|
|
2830
|
+
/**
|
|
2831
|
+
* Decrement by 1 and return new value.
|
|
2832
|
+
*/
|
|
2833
|
+
decrement() {
|
|
2834
|
+
const value = this.counter.decrement();
|
|
2835
|
+
this.scheduleSync();
|
|
2836
|
+
this.schedulePersist();
|
|
2837
|
+
return value;
|
|
2838
|
+
}
|
|
2839
|
+
/**
|
|
2840
|
+
* Add delta (positive or negative) and return new value.
|
|
2841
|
+
*/
|
|
2842
|
+
addAndGet(delta) {
|
|
2843
|
+
const value = this.counter.addAndGet(delta);
|
|
2844
|
+
if (delta !== 0) {
|
|
2845
|
+
this.scheduleSync();
|
|
2846
|
+
this.schedulePersist();
|
|
2847
|
+
}
|
|
2848
|
+
return value;
|
|
2849
|
+
}
|
|
2850
|
+
/**
|
|
2851
|
+
* Get state for sync.
|
|
2852
|
+
*/
|
|
2853
|
+
getState() {
|
|
2854
|
+
return this.counter.getState();
|
|
2855
|
+
}
|
|
2856
|
+
/**
|
|
2857
|
+
* Merge remote state.
|
|
2858
|
+
*/
|
|
2859
|
+
merge(remote) {
|
|
2860
|
+
this.counter.merge(remote);
|
|
2861
|
+
}
|
|
2862
|
+
/**
|
|
2863
|
+
* Subscribe to value changes.
|
|
2864
|
+
*/
|
|
2865
|
+
subscribe(listener) {
|
|
2866
|
+
return this.counter.subscribe(listener);
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* Get the counter name.
|
|
2870
|
+
*/
|
|
2871
|
+
getName() {
|
|
2872
|
+
return this.name;
|
|
2873
|
+
}
|
|
2874
|
+
/**
|
|
2875
|
+
* Cleanup resources.
|
|
2876
|
+
*/
|
|
2877
|
+
dispose() {
|
|
2878
|
+
if (this.unsubscribeFromUpdates) {
|
|
2879
|
+
this.unsubscribeFromUpdates();
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
/**
|
|
2883
|
+
* Schedule sync to server with debouncing.
|
|
2884
|
+
* Batches rapid increments to avoid network spam.
|
|
2885
|
+
*/
|
|
2886
|
+
scheduleSync() {
|
|
2887
|
+
if (this.syncScheduled) return;
|
|
2888
|
+
this.syncScheduled = true;
|
|
2889
|
+
setTimeout(() => {
|
|
2890
|
+
this.syncScheduled = false;
|
|
2891
|
+
this.syncEngine.syncCounter(this.name, this.counter.getState());
|
|
2892
|
+
}, 50);
|
|
2893
|
+
}
|
|
2894
|
+
};
|
|
2895
|
+
|
|
2896
|
+
// src/EventJournalReader.ts
|
|
2897
|
+
var EventJournalReader = class {
|
|
2898
|
+
constructor(syncEngine) {
|
|
2899
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
2900
|
+
this.subscriptionCounter = 0;
|
|
2901
|
+
this.syncEngine = syncEngine;
|
|
2902
|
+
}
|
|
2903
|
+
/**
|
|
2904
|
+
* Read events from sequence with optional limit.
|
|
2905
|
+
*
|
|
2906
|
+
* @param sequence Starting sequence (inclusive)
|
|
2907
|
+
* @param limit Maximum events to return (default: 100)
|
|
2908
|
+
* @returns Promise resolving to array of events
|
|
2909
|
+
*/
|
|
2910
|
+
async readFrom(sequence, limit = 100) {
|
|
2911
|
+
const requestId = this.generateRequestId();
|
|
2912
|
+
return new Promise((resolve, reject) => {
|
|
2913
|
+
const timeout = setTimeout(() => {
|
|
2914
|
+
reject(new Error("Journal read timeout"));
|
|
2915
|
+
}, 1e4);
|
|
2916
|
+
const handleResponse = (message) => {
|
|
2917
|
+
if (message.type === "JOURNAL_READ_RESPONSE" && message.requestId === requestId) {
|
|
2918
|
+
clearTimeout(timeout);
|
|
2919
|
+
this.syncEngine.off("message", handleResponse);
|
|
2920
|
+
const events = message.events.map((e) => this.parseEvent(e));
|
|
2921
|
+
resolve(events);
|
|
2922
|
+
}
|
|
2923
|
+
};
|
|
2924
|
+
this.syncEngine.on("message", handleResponse);
|
|
2925
|
+
this.syncEngine.send({
|
|
2926
|
+
type: "JOURNAL_READ",
|
|
2927
|
+
requestId,
|
|
2928
|
+
fromSequence: sequence.toString(),
|
|
2929
|
+
limit
|
|
2930
|
+
});
|
|
2931
|
+
});
|
|
2932
|
+
}
|
|
2933
|
+
/**
|
|
2934
|
+
* Read events for a specific map.
|
|
2935
|
+
*
|
|
2936
|
+
* @param mapName Map name to filter
|
|
2937
|
+
* @param sequence Starting sequence (default: 0n)
|
|
2938
|
+
* @param limit Maximum events to return (default: 100)
|
|
2939
|
+
*/
|
|
2940
|
+
async readMapEvents(mapName, sequence = 0n, limit = 100) {
|
|
2941
|
+
const requestId = this.generateRequestId();
|
|
2942
|
+
return new Promise((resolve, reject) => {
|
|
2943
|
+
const timeout = setTimeout(() => {
|
|
2944
|
+
reject(new Error("Journal read timeout"));
|
|
2945
|
+
}, 1e4);
|
|
2946
|
+
const handleResponse = (message) => {
|
|
2947
|
+
if (message.type === "JOURNAL_READ_RESPONSE" && message.requestId === requestId) {
|
|
2948
|
+
clearTimeout(timeout);
|
|
2949
|
+
this.syncEngine.off("message", handleResponse);
|
|
2950
|
+
const events = message.events.map((e) => this.parseEvent(e));
|
|
2951
|
+
resolve(events);
|
|
2952
|
+
}
|
|
2953
|
+
};
|
|
2954
|
+
this.syncEngine.on("message", handleResponse);
|
|
2955
|
+
this.syncEngine.send({
|
|
2956
|
+
type: "JOURNAL_READ",
|
|
2957
|
+
requestId,
|
|
2958
|
+
fromSequence: sequence.toString(),
|
|
2959
|
+
limit,
|
|
2960
|
+
mapName
|
|
2961
|
+
});
|
|
2962
|
+
});
|
|
2963
|
+
}
|
|
2964
|
+
/**
|
|
2965
|
+
* Subscribe to new journal events.
|
|
2966
|
+
*
|
|
2967
|
+
* @param listener Callback for each event
|
|
2968
|
+
* @param options Subscription options
|
|
2969
|
+
* @returns Unsubscribe function
|
|
2970
|
+
*/
|
|
2971
|
+
subscribe(listener, options = {}) {
|
|
2972
|
+
const subscriptionId = this.generateRequestId();
|
|
2973
|
+
this.listeners.set(subscriptionId, listener);
|
|
2974
|
+
const handleEvent = (message) => {
|
|
2975
|
+
if (message.type === "JOURNAL_EVENT") {
|
|
2976
|
+
const event = this.parseEvent(message.event);
|
|
2977
|
+
if (options.mapName && event.mapName !== options.mapName) return;
|
|
2978
|
+
if (options.types && !options.types.includes(event.type)) return;
|
|
2979
|
+
const listenerFn = this.listeners.get(subscriptionId);
|
|
2980
|
+
if (listenerFn) {
|
|
2981
|
+
try {
|
|
2982
|
+
listenerFn(event);
|
|
2983
|
+
} catch (e) {
|
|
2984
|
+
logger.error({ err: e }, "Journal listener error");
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
};
|
|
2989
|
+
this.syncEngine.on("message", handleEvent);
|
|
2990
|
+
this.syncEngine.send({
|
|
2991
|
+
type: "JOURNAL_SUBSCRIBE",
|
|
2992
|
+
requestId: subscriptionId,
|
|
2993
|
+
fromSequence: options.fromSequence?.toString(),
|
|
2994
|
+
mapName: options.mapName,
|
|
2995
|
+
types: options.types
|
|
2996
|
+
});
|
|
2997
|
+
return () => {
|
|
2998
|
+
this.listeners.delete(subscriptionId);
|
|
2999
|
+
this.syncEngine.off("message", handleEvent);
|
|
3000
|
+
this.syncEngine.send({
|
|
3001
|
+
type: "JOURNAL_UNSUBSCRIBE",
|
|
3002
|
+
subscriptionId
|
|
3003
|
+
});
|
|
3004
|
+
};
|
|
3005
|
+
}
|
|
3006
|
+
/**
|
|
3007
|
+
* Get the latest sequence number from server.
|
|
3008
|
+
*/
|
|
3009
|
+
async getLatestSequence() {
|
|
3010
|
+
const events = await this.readFrom(0n, 1);
|
|
3011
|
+
if (events.length === 0) return 0n;
|
|
3012
|
+
return events[events.length - 1].sequence;
|
|
3013
|
+
}
|
|
3014
|
+
/**
|
|
3015
|
+
* Parse network event data to JournalEvent.
|
|
3016
|
+
*/
|
|
3017
|
+
parseEvent(raw) {
|
|
3018
|
+
return {
|
|
3019
|
+
sequence: BigInt(raw.sequence),
|
|
3020
|
+
type: raw.type,
|
|
3021
|
+
mapName: raw.mapName,
|
|
3022
|
+
key: raw.key,
|
|
3023
|
+
value: raw.value,
|
|
3024
|
+
previousValue: raw.previousValue,
|
|
3025
|
+
timestamp: raw.timestamp,
|
|
3026
|
+
nodeId: raw.nodeId,
|
|
3027
|
+
metadata: raw.metadata
|
|
3028
|
+
};
|
|
3029
|
+
}
|
|
3030
|
+
/**
|
|
3031
|
+
* Generate unique request ID.
|
|
3032
|
+
*/
|
|
3033
|
+
generateRequestId() {
|
|
3034
|
+
return `journal_${Date.now()}_${++this.subscriptionCounter}`;
|
|
3035
|
+
}
|
|
3036
|
+
};
|
|
3037
|
+
|
|
3038
|
+
// src/cluster/ClusterClient.ts
|
|
3039
|
+
import {
|
|
3040
|
+
DEFAULT_CONNECTION_POOL_CONFIG as DEFAULT_CONNECTION_POOL_CONFIG2,
|
|
3041
|
+
DEFAULT_PARTITION_ROUTER_CONFIG as DEFAULT_PARTITION_ROUTER_CONFIG2,
|
|
3042
|
+
DEFAULT_CIRCUIT_BREAKER_CONFIG,
|
|
3043
|
+
serialize as serialize3
|
|
3044
|
+
} from "@topgunbuild/core";
|
|
3045
|
+
|
|
3046
|
+
// src/cluster/ConnectionPool.ts
|
|
3047
|
+
import {
|
|
3048
|
+
DEFAULT_CONNECTION_POOL_CONFIG
|
|
3049
|
+
} from "@topgunbuild/core";
|
|
3050
|
+
import { serialize as serialize2, deserialize as deserialize2 } from "@topgunbuild/core";
|
|
3051
|
+
var ConnectionPool = class {
|
|
3052
|
+
constructor(config = {}) {
|
|
3053
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
3054
|
+
this.connections = /* @__PURE__ */ new Map();
|
|
3055
|
+
this.primaryNodeId = null;
|
|
3056
|
+
this.healthCheckTimer = null;
|
|
3057
|
+
this.authToken = null;
|
|
3058
|
+
this.config = {
|
|
3059
|
+
...DEFAULT_CONNECTION_POOL_CONFIG,
|
|
3060
|
+
...config
|
|
3061
|
+
};
|
|
3062
|
+
}
|
|
3063
|
+
// ============================================
|
|
3064
|
+
// Event Emitter Methods (browser-compatible)
|
|
3065
|
+
// ============================================
|
|
3066
|
+
on(event, listener) {
|
|
3067
|
+
if (!this.listeners.has(event)) {
|
|
3068
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
3069
|
+
}
|
|
3070
|
+
this.listeners.get(event).add(listener);
|
|
3071
|
+
return this;
|
|
3072
|
+
}
|
|
3073
|
+
off(event, listener) {
|
|
3074
|
+
this.listeners.get(event)?.delete(listener);
|
|
3075
|
+
return this;
|
|
3076
|
+
}
|
|
3077
|
+
emit(event, ...args) {
|
|
3078
|
+
const eventListeners = this.listeners.get(event);
|
|
3079
|
+
if (!eventListeners || eventListeners.size === 0) {
|
|
3080
|
+
return false;
|
|
3081
|
+
}
|
|
3082
|
+
for (const listener of eventListeners) {
|
|
3083
|
+
try {
|
|
3084
|
+
listener(...args);
|
|
3085
|
+
} catch (err) {
|
|
3086
|
+
logger.error({ event, err }, "Error in event listener");
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
return true;
|
|
3090
|
+
}
|
|
3091
|
+
removeAllListeners(event) {
|
|
3092
|
+
if (event) {
|
|
3093
|
+
this.listeners.delete(event);
|
|
3094
|
+
} else {
|
|
3095
|
+
this.listeners.clear();
|
|
3096
|
+
}
|
|
3097
|
+
return this;
|
|
3098
|
+
}
|
|
3099
|
+
/**
|
|
3100
|
+
* Set authentication token for all connections
|
|
3101
|
+
*/
|
|
3102
|
+
setAuthToken(token) {
|
|
3103
|
+
this.authToken = token;
|
|
3104
|
+
for (const conn of this.connections.values()) {
|
|
3105
|
+
if (conn.state === "CONNECTED") {
|
|
3106
|
+
this.sendAuth(conn);
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
/**
|
|
3111
|
+
* Add a node to the connection pool
|
|
3112
|
+
*/
|
|
3113
|
+
async addNode(nodeId, endpoint) {
|
|
3114
|
+
if (this.connections.has(nodeId)) {
|
|
3115
|
+
const existing = this.connections.get(nodeId);
|
|
3116
|
+
if (existing.endpoint !== endpoint) {
|
|
3117
|
+
await this.removeNode(nodeId);
|
|
3118
|
+
} else {
|
|
3119
|
+
return;
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
const connection = {
|
|
3123
|
+
nodeId,
|
|
3124
|
+
endpoint,
|
|
3125
|
+
socket: null,
|
|
3126
|
+
state: "DISCONNECTED",
|
|
3127
|
+
lastSeen: 0,
|
|
3128
|
+
latencyMs: 0,
|
|
3129
|
+
reconnectAttempts: 0,
|
|
3130
|
+
reconnectTimer: null,
|
|
3131
|
+
pendingMessages: []
|
|
3132
|
+
};
|
|
3133
|
+
this.connections.set(nodeId, connection);
|
|
3134
|
+
if (!this.primaryNodeId) {
|
|
3135
|
+
this.primaryNodeId = nodeId;
|
|
3136
|
+
}
|
|
3137
|
+
await this.connect(nodeId);
|
|
3138
|
+
}
|
|
3139
|
+
/**
|
|
3140
|
+
* Remove a node from the connection pool
|
|
3141
|
+
*/
|
|
3142
|
+
async removeNode(nodeId) {
|
|
3143
|
+
const connection = this.connections.get(nodeId);
|
|
3144
|
+
if (!connection) return;
|
|
3145
|
+
if (connection.reconnectTimer) {
|
|
3146
|
+
clearTimeout(connection.reconnectTimer);
|
|
3147
|
+
connection.reconnectTimer = null;
|
|
3148
|
+
}
|
|
3149
|
+
if (connection.socket) {
|
|
3150
|
+
connection.socket.onclose = null;
|
|
3151
|
+
connection.socket.close();
|
|
3152
|
+
connection.socket = null;
|
|
3153
|
+
}
|
|
3154
|
+
this.connections.delete(nodeId);
|
|
3155
|
+
if (this.primaryNodeId === nodeId) {
|
|
3156
|
+
this.primaryNodeId = this.connections.size > 0 ? this.connections.keys().next().value ?? null : null;
|
|
3157
|
+
}
|
|
3158
|
+
logger.info({ nodeId }, "Node removed from connection pool");
|
|
3159
|
+
}
|
|
3160
|
+
/**
|
|
3161
|
+
* Get connection for a specific node
|
|
3162
|
+
*/
|
|
3163
|
+
getConnection(nodeId) {
|
|
3164
|
+
const connection = this.connections.get(nodeId);
|
|
3165
|
+
if (!connection || connection.state !== "AUTHENTICATED") {
|
|
3166
|
+
return null;
|
|
3167
|
+
}
|
|
3168
|
+
return connection.socket;
|
|
3169
|
+
}
|
|
3170
|
+
/**
|
|
3171
|
+
* Get primary connection (first/seed node)
|
|
3172
|
+
*/
|
|
3173
|
+
getPrimaryConnection() {
|
|
3174
|
+
if (!this.primaryNodeId) return null;
|
|
3175
|
+
return this.getConnection(this.primaryNodeId);
|
|
3176
|
+
}
|
|
3177
|
+
/**
|
|
3178
|
+
* Get any healthy connection
|
|
3179
|
+
*/
|
|
3180
|
+
getAnyHealthyConnection() {
|
|
3181
|
+
for (const [nodeId, conn] of this.connections) {
|
|
3182
|
+
if (conn.state === "AUTHENTICATED" && conn.socket) {
|
|
3183
|
+
return { nodeId, socket: conn.socket };
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
return null;
|
|
3187
|
+
}
|
|
3188
|
+
/**
|
|
3189
|
+
* Send message to a specific node
|
|
3190
|
+
*/
|
|
3191
|
+
send(nodeId, message) {
|
|
3192
|
+
const connection = this.connections.get(nodeId);
|
|
3193
|
+
if (!connection) {
|
|
3194
|
+
logger.warn({ nodeId }, "Cannot send: node not in pool");
|
|
3195
|
+
return false;
|
|
3196
|
+
}
|
|
3197
|
+
const data = serialize2(message);
|
|
3198
|
+
if (connection.state === "AUTHENTICATED" && connection.socket?.readyState === WebSocket.OPEN) {
|
|
3199
|
+
connection.socket.send(data);
|
|
3200
|
+
return true;
|
|
3201
|
+
}
|
|
3202
|
+
if (connection.pendingMessages.length < 1e3) {
|
|
3203
|
+
connection.pendingMessages.push(data);
|
|
3204
|
+
return true;
|
|
3205
|
+
}
|
|
3206
|
+
logger.warn({ nodeId }, "Message queue full, dropping message");
|
|
3207
|
+
return false;
|
|
3208
|
+
}
|
|
3209
|
+
/**
|
|
3210
|
+
* Send message to primary node
|
|
3211
|
+
*/
|
|
3212
|
+
sendToPrimary(message) {
|
|
3213
|
+
if (!this.primaryNodeId) {
|
|
3214
|
+
logger.warn("No primary node available");
|
|
3215
|
+
return false;
|
|
3216
|
+
}
|
|
3217
|
+
return this.send(this.primaryNodeId, message);
|
|
3218
|
+
}
|
|
3219
|
+
/**
|
|
3220
|
+
* Get health status for all nodes
|
|
3221
|
+
*/
|
|
3222
|
+
getHealthStatus() {
|
|
3223
|
+
const status = /* @__PURE__ */ new Map();
|
|
3224
|
+
for (const [nodeId, conn] of this.connections) {
|
|
3225
|
+
status.set(nodeId, {
|
|
3226
|
+
nodeId,
|
|
3227
|
+
state: conn.state,
|
|
3228
|
+
lastSeen: conn.lastSeen,
|
|
3229
|
+
latencyMs: conn.latencyMs,
|
|
3230
|
+
reconnectAttempts: conn.reconnectAttempts
|
|
3231
|
+
});
|
|
3232
|
+
}
|
|
3233
|
+
return status;
|
|
3234
|
+
}
|
|
3235
|
+
/**
|
|
3236
|
+
* Get list of connected node IDs
|
|
3237
|
+
*/
|
|
3238
|
+
getConnectedNodes() {
|
|
3239
|
+
return Array.from(this.connections.entries()).filter(([_, conn]) => conn.state === "AUTHENTICATED").map(([nodeId]) => nodeId);
|
|
3240
|
+
}
|
|
3241
|
+
/**
|
|
3242
|
+
* Get all node IDs
|
|
3243
|
+
*/
|
|
3244
|
+
getAllNodes() {
|
|
3245
|
+
return Array.from(this.connections.keys());
|
|
3246
|
+
}
|
|
3247
|
+
/**
|
|
3248
|
+
* Check if node is connected and authenticated
|
|
3249
|
+
*/
|
|
3250
|
+
isNodeConnected(nodeId) {
|
|
3251
|
+
const conn = this.connections.get(nodeId);
|
|
3252
|
+
return conn?.state === "AUTHENTICATED";
|
|
3253
|
+
}
|
|
3254
|
+
/**
|
|
3255
|
+
* Check if connected to a specific node.
|
|
3256
|
+
* Alias for isNodeConnected() for IConnectionProvider compatibility.
|
|
3257
|
+
*/
|
|
3258
|
+
isConnected(nodeId) {
|
|
3259
|
+
return this.isNodeConnected(nodeId);
|
|
3260
|
+
}
|
|
3261
|
+
/**
|
|
3262
|
+
* Start health monitoring
|
|
3263
|
+
*/
|
|
3264
|
+
startHealthCheck() {
|
|
3265
|
+
if (this.healthCheckTimer) return;
|
|
3266
|
+
this.healthCheckTimer = setInterval(() => {
|
|
3267
|
+
this.performHealthCheck();
|
|
3268
|
+
}, this.config.healthCheckIntervalMs);
|
|
3269
|
+
}
|
|
3270
|
+
/**
|
|
3271
|
+
* Stop health monitoring
|
|
3272
|
+
*/
|
|
3273
|
+
stopHealthCheck() {
|
|
3274
|
+
if (this.healthCheckTimer) {
|
|
3275
|
+
clearInterval(this.healthCheckTimer);
|
|
3276
|
+
this.healthCheckTimer = null;
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
/**
|
|
3280
|
+
* Close all connections and cleanup
|
|
3281
|
+
*/
|
|
3282
|
+
close() {
|
|
3283
|
+
this.stopHealthCheck();
|
|
3284
|
+
for (const nodeId of this.connections.keys()) {
|
|
3285
|
+
this.removeNode(nodeId);
|
|
3286
|
+
}
|
|
3287
|
+
this.connections.clear();
|
|
3288
|
+
this.primaryNodeId = null;
|
|
3289
|
+
}
|
|
3290
|
+
// ============================================
|
|
3291
|
+
// Private Methods
|
|
3292
|
+
// ============================================
|
|
3293
|
+
async connect(nodeId) {
|
|
3294
|
+
const connection = this.connections.get(nodeId);
|
|
3295
|
+
if (!connection) return;
|
|
3296
|
+
if (connection.state === "CONNECTING" || connection.state === "CONNECTED") {
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
3299
|
+
connection.state = "CONNECTING";
|
|
3300
|
+
logger.info({ nodeId, endpoint: connection.endpoint }, "Connecting to node");
|
|
3301
|
+
try {
|
|
3302
|
+
const socket = new WebSocket(connection.endpoint);
|
|
3303
|
+
socket.binaryType = "arraybuffer";
|
|
3304
|
+
connection.socket = socket;
|
|
3305
|
+
socket.onopen = () => {
|
|
3306
|
+
connection.state = "CONNECTED";
|
|
3307
|
+
connection.reconnectAttempts = 0;
|
|
3308
|
+
connection.lastSeen = Date.now();
|
|
3309
|
+
logger.info({ nodeId }, "Connected to node");
|
|
3310
|
+
this.emit("node:connected", nodeId);
|
|
3311
|
+
if (this.authToken) {
|
|
3312
|
+
this.sendAuth(connection);
|
|
3313
|
+
}
|
|
3314
|
+
};
|
|
3315
|
+
socket.onmessage = (event) => {
|
|
3316
|
+
connection.lastSeen = Date.now();
|
|
3317
|
+
this.handleMessage(nodeId, event);
|
|
3318
|
+
};
|
|
3319
|
+
socket.onerror = (error) => {
|
|
3320
|
+
logger.error({ nodeId, error }, "WebSocket error");
|
|
3321
|
+
this.emit("error", nodeId, error instanceof Error ? error : new Error("WebSocket error"));
|
|
3322
|
+
};
|
|
3323
|
+
socket.onclose = () => {
|
|
3324
|
+
const wasConnected = connection.state === "AUTHENTICATED";
|
|
3325
|
+
connection.state = "DISCONNECTED";
|
|
3326
|
+
connection.socket = null;
|
|
3327
|
+
if (wasConnected) {
|
|
3328
|
+
this.emit("node:disconnected", nodeId, "Connection closed");
|
|
3329
|
+
}
|
|
3330
|
+
this.scheduleReconnect(nodeId);
|
|
3331
|
+
};
|
|
3332
|
+
} catch (error) {
|
|
3333
|
+
connection.state = "FAILED";
|
|
3334
|
+
logger.error({ nodeId, error }, "Failed to connect");
|
|
3335
|
+
this.scheduleReconnect(nodeId);
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
sendAuth(connection) {
|
|
3339
|
+
if (!this.authToken || !connection.socket) return;
|
|
3340
|
+
connection.socket.send(serialize2({
|
|
3341
|
+
type: "AUTH",
|
|
3342
|
+
token: this.authToken
|
|
3343
|
+
}));
|
|
3344
|
+
}
|
|
3345
|
+
handleMessage(nodeId, event) {
|
|
3346
|
+
const connection = this.connections.get(nodeId);
|
|
3347
|
+
if (!connection) return;
|
|
3348
|
+
let message;
|
|
3349
|
+
try {
|
|
3350
|
+
if (event.data instanceof ArrayBuffer) {
|
|
3351
|
+
message = deserialize2(new Uint8Array(event.data));
|
|
3352
|
+
} else {
|
|
3353
|
+
message = JSON.parse(event.data);
|
|
3354
|
+
}
|
|
3355
|
+
} catch (e) {
|
|
3356
|
+
logger.error({ nodeId, error: e }, "Failed to parse message");
|
|
3357
|
+
return;
|
|
3358
|
+
}
|
|
3359
|
+
if (message.type === "AUTH_ACK") {
|
|
3360
|
+
connection.state = "AUTHENTICATED";
|
|
3361
|
+
logger.info({ nodeId }, "Authenticated with node");
|
|
3362
|
+
this.emit("node:healthy", nodeId);
|
|
3363
|
+
this.flushPendingMessages(connection);
|
|
3364
|
+
return;
|
|
3365
|
+
}
|
|
3366
|
+
if (message.type === "AUTH_REQUIRED") {
|
|
3367
|
+
if (this.authToken) {
|
|
3368
|
+
this.sendAuth(connection);
|
|
3369
|
+
}
|
|
3370
|
+
return;
|
|
3371
|
+
}
|
|
3372
|
+
if (message.type === "AUTH_FAIL") {
|
|
3373
|
+
logger.error({ nodeId, error: message.error }, "Authentication failed");
|
|
3374
|
+
connection.state = "FAILED";
|
|
3375
|
+
return;
|
|
3376
|
+
}
|
|
3377
|
+
if (message.type === "PONG") {
|
|
3378
|
+
if (message.timestamp) {
|
|
3379
|
+
connection.latencyMs = Date.now() - message.timestamp;
|
|
3380
|
+
}
|
|
3381
|
+
return;
|
|
3382
|
+
}
|
|
3383
|
+
if (message.type === "PARTITION_MAP" || message.type === "PARTITION_MAP_DELTA") {
|
|
3384
|
+
this.emit("message", nodeId, message);
|
|
3385
|
+
return;
|
|
3386
|
+
}
|
|
3387
|
+
this.emit("message", nodeId, message);
|
|
3388
|
+
}
|
|
3389
|
+
flushPendingMessages(connection) {
|
|
3390
|
+
if (!connection.socket || connection.state !== "AUTHENTICATED") return;
|
|
3391
|
+
const pending = connection.pendingMessages;
|
|
3392
|
+
connection.pendingMessages = [];
|
|
3393
|
+
for (const data of pending) {
|
|
3394
|
+
if (connection.socket.readyState === WebSocket.OPEN) {
|
|
3395
|
+
connection.socket.send(data);
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
if (pending.length > 0) {
|
|
3399
|
+
logger.debug({ nodeId: connection.nodeId, count: pending.length }, "Flushed pending messages");
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
3402
|
+
scheduleReconnect(nodeId) {
|
|
3403
|
+
const connection = this.connections.get(nodeId);
|
|
3404
|
+
if (!connection) return;
|
|
3405
|
+
if (connection.reconnectTimer) {
|
|
3406
|
+
clearTimeout(connection.reconnectTimer);
|
|
3407
|
+
connection.reconnectTimer = null;
|
|
3408
|
+
}
|
|
3409
|
+
if (connection.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
3410
|
+
connection.state = "FAILED";
|
|
3411
|
+
logger.error({ nodeId, attempts: connection.reconnectAttempts }, "Max reconnect attempts reached");
|
|
3412
|
+
this.emit("node:unhealthy", nodeId, "Max reconnect attempts reached");
|
|
3413
|
+
return;
|
|
3414
|
+
}
|
|
3415
|
+
const delay = Math.min(
|
|
3416
|
+
this.config.reconnectDelayMs * Math.pow(2, connection.reconnectAttempts),
|
|
3417
|
+
this.config.maxReconnectDelayMs
|
|
3418
|
+
);
|
|
3419
|
+
connection.state = "RECONNECTING";
|
|
3420
|
+
connection.reconnectAttempts++;
|
|
3421
|
+
logger.info({ nodeId, delay, attempt: connection.reconnectAttempts }, "Scheduling reconnect");
|
|
3422
|
+
connection.reconnectTimer = setTimeout(() => {
|
|
3423
|
+
connection.reconnectTimer = null;
|
|
3424
|
+
this.connect(nodeId);
|
|
3425
|
+
}, delay);
|
|
3426
|
+
}
|
|
3427
|
+
performHealthCheck() {
|
|
3428
|
+
const now = Date.now();
|
|
3429
|
+
for (const [nodeId, connection] of this.connections) {
|
|
3430
|
+
if (connection.state !== "AUTHENTICATED") continue;
|
|
3431
|
+
const timeSinceLastSeen = now - connection.lastSeen;
|
|
3432
|
+
if (timeSinceLastSeen > this.config.healthCheckIntervalMs * 3) {
|
|
3433
|
+
logger.warn({ nodeId, timeSinceLastSeen }, "Node appears stale, sending ping");
|
|
3434
|
+
}
|
|
3435
|
+
if (connection.socket?.readyState === WebSocket.OPEN) {
|
|
3436
|
+
connection.socket.send(serialize2({
|
|
3437
|
+
type: "PING",
|
|
3438
|
+
timestamp: now
|
|
3439
|
+
}));
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
};
|
|
3444
|
+
|
|
3445
|
+
// src/cluster/PartitionRouter.ts
|
|
3446
|
+
import {
|
|
3447
|
+
DEFAULT_PARTITION_ROUTER_CONFIG,
|
|
3448
|
+
PARTITION_COUNT,
|
|
3449
|
+
hashString
|
|
3450
|
+
} from "@topgunbuild/core";
|
|
3451
|
+
var PartitionRouter = class {
|
|
3452
|
+
constructor(connectionPool, config = {}) {
|
|
3453
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
3454
|
+
this.partitionMap = null;
|
|
3455
|
+
this.lastRefreshTime = 0;
|
|
3456
|
+
this.refreshTimer = null;
|
|
3457
|
+
this.pendingRefresh = null;
|
|
3458
|
+
this.connectionPool = connectionPool;
|
|
3459
|
+
this.config = {
|
|
3460
|
+
...DEFAULT_PARTITION_ROUTER_CONFIG,
|
|
3461
|
+
...config
|
|
3462
|
+
};
|
|
3463
|
+
this.connectionPool.on("message", (nodeId, message) => {
|
|
3464
|
+
if (message.type === "PARTITION_MAP") {
|
|
3465
|
+
this.handlePartitionMap(message);
|
|
3466
|
+
} else if (message.type === "PARTITION_MAP_DELTA") {
|
|
3467
|
+
this.handlePartitionMapDelta(message);
|
|
3468
|
+
}
|
|
3469
|
+
});
|
|
3470
|
+
}
|
|
3471
|
+
// ============================================
|
|
3472
|
+
// Event Emitter Methods (browser-compatible)
|
|
3473
|
+
// ============================================
|
|
3474
|
+
on(event, listener) {
|
|
3475
|
+
if (!this.listeners.has(event)) {
|
|
3476
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
3477
|
+
}
|
|
3478
|
+
this.listeners.get(event).add(listener);
|
|
3479
|
+
return this;
|
|
3480
|
+
}
|
|
3481
|
+
off(event, listener) {
|
|
3482
|
+
this.listeners.get(event)?.delete(listener);
|
|
3483
|
+
return this;
|
|
3484
|
+
}
|
|
3485
|
+
once(event, listener) {
|
|
3486
|
+
const wrapper = (...args) => {
|
|
3487
|
+
this.off(event, wrapper);
|
|
3488
|
+
listener(...args);
|
|
3489
|
+
};
|
|
3490
|
+
return this.on(event, wrapper);
|
|
3491
|
+
}
|
|
3492
|
+
emit(event, ...args) {
|
|
3493
|
+
const eventListeners = this.listeners.get(event);
|
|
3494
|
+
if (!eventListeners || eventListeners.size === 0) {
|
|
3495
|
+
return false;
|
|
3496
|
+
}
|
|
3497
|
+
for (const listener of eventListeners) {
|
|
3498
|
+
try {
|
|
3499
|
+
listener(...args);
|
|
3500
|
+
} catch (err) {
|
|
3501
|
+
logger.error({ event, err }, "Error in event listener");
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
return true;
|
|
3505
|
+
}
|
|
3506
|
+
removeListener(event, listener) {
|
|
3507
|
+
return this.off(event, listener);
|
|
3508
|
+
}
|
|
3509
|
+
removeAllListeners(event) {
|
|
3510
|
+
if (event) {
|
|
3511
|
+
this.listeners.delete(event);
|
|
3512
|
+
} else {
|
|
3513
|
+
this.listeners.clear();
|
|
3514
|
+
}
|
|
3515
|
+
return this;
|
|
3516
|
+
}
|
|
3517
|
+
/**
|
|
3518
|
+
* Get the partition ID for a given key
|
|
3519
|
+
*/
|
|
3520
|
+
getPartitionId(key) {
|
|
3521
|
+
return Math.abs(hashString(key)) % PARTITION_COUNT;
|
|
3522
|
+
}
|
|
3523
|
+
/**
|
|
3524
|
+
* Route a key to the owner node
|
|
3525
|
+
*/
|
|
3526
|
+
route(key) {
|
|
3527
|
+
if (!this.partitionMap) {
|
|
3528
|
+
return null;
|
|
3529
|
+
}
|
|
3530
|
+
const partitionId = this.getPartitionId(key);
|
|
3531
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
3532
|
+
if (!partition) {
|
|
3533
|
+
logger.warn({ key, partitionId }, "Partition not found in map");
|
|
3534
|
+
return null;
|
|
3535
|
+
}
|
|
3536
|
+
return {
|
|
3537
|
+
nodeId: partition.ownerNodeId,
|
|
3538
|
+
partitionId,
|
|
3539
|
+
isOwner: true,
|
|
3540
|
+
isBackup: false
|
|
3541
|
+
};
|
|
3542
|
+
}
|
|
3543
|
+
/**
|
|
3544
|
+
* Route a key and get the WebSocket connection to use
|
|
3545
|
+
*/
|
|
3546
|
+
routeToConnection(key) {
|
|
3547
|
+
const routing = this.route(key);
|
|
3548
|
+
if (!routing) {
|
|
3549
|
+
if (this.config.fallbackMode === "forward") {
|
|
3550
|
+
const primary = this.connectionPool.getAnyHealthyConnection();
|
|
3551
|
+
if (primary) {
|
|
3552
|
+
return primary;
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
return null;
|
|
3556
|
+
}
|
|
3557
|
+
const socket = this.connectionPool.getConnection(routing.nodeId);
|
|
3558
|
+
if (socket) {
|
|
3559
|
+
return { nodeId: routing.nodeId, socket };
|
|
3560
|
+
}
|
|
3561
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === routing.partitionId);
|
|
3562
|
+
if (partition) {
|
|
3563
|
+
for (const backupId of partition.backupNodeIds) {
|
|
3564
|
+
const backupSocket = this.connectionPool.getConnection(backupId);
|
|
3565
|
+
if (backupSocket) {
|
|
3566
|
+
logger.debug({ key, owner: routing.nodeId, backup: backupId }, "Using backup node");
|
|
3567
|
+
return { nodeId: backupId, socket: backupSocket };
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
if (this.config.fallbackMode === "forward") {
|
|
3572
|
+
return this.connectionPool.getAnyHealthyConnection();
|
|
3573
|
+
}
|
|
3574
|
+
return null;
|
|
3575
|
+
}
|
|
3576
|
+
/**
|
|
3577
|
+
* Get routing info for multiple keys (batch routing)
|
|
3578
|
+
*/
|
|
3579
|
+
routeBatch(keys) {
|
|
3580
|
+
const result = /* @__PURE__ */ new Map();
|
|
3581
|
+
for (const key of keys) {
|
|
3582
|
+
const routing = this.route(key);
|
|
3583
|
+
if (routing) {
|
|
3584
|
+
const nodeId = routing.nodeId;
|
|
3585
|
+
if (!result.has(nodeId)) {
|
|
3586
|
+
result.set(nodeId, []);
|
|
3587
|
+
}
|
|
3588
|
+
result.get(nodeId).push({ ...routing, key });
|
|
3589
|
+
}
|
|
3590
|
+
}
|
|
3591
|
+
return result;
|
|
3592
|
+
}
|
|
3593
|
+
/**
|
|
3594
|
+
* Get all partitions owned by a specific node
|
|
3595
|
+
*/
|
|
3596
|
+
getPartitionsForNode(nodeId) {
|
|
3597
|
+
if (!this.partitionMap) return [];
|
|
3598
|
+
return this.partitionMap.partitions.filter((p) => p.ownerNodeId === nodeId).map((p) => p.partitionId);
|
|
3599
|
+
}
|
|
3600
|
+
/**
|
|
3601
|
+
* Get current partition map version
|
|
3602
|
+
*/
|
|
3603
|
+
getMapVersion() {
|
|
3604
|
+
return this.partitionMap?.version ?? 0;
|
|
3605
|
+
}
|
|
3606
|
+
/**
|
|
3607
|
+
* Check if partition map is available
|
|
3608
|
+
*/
|
|
3609
|
+
hasPartitionMap() {
|
|
3610
|
+
return this.partitionMap !== null;
|
|
3611
|
+
}
|
|
3612
|
+
/**
|
|
3613
|
+
* Get owner node for a key.
|
|
3614
|
+
* Returns null if partition map is not available.
|
|
3615
|
+
*/
|
|
3616
|
+
getOwner(key) {
|
|
3617
|
+
if (!this.partitionMap) return null;
|
|
3618
|
+
const partitionId = this.getPartitionId(key);
|
|
3619
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
3620
|
+
return partition?.ownerNodeId ?? null;
|
|
3621
|
+
}
|
|
3622
|
+
/**
|
|
3623
|
+
* Get backup nodes for a key.
|
|
3624
|
+
* Returns empty array if partition map is not available.
|
|
3625
|
+
*/
|
|
3626
|
+
getBackups(key) {
|
|
3627
|
+
if (!this.partitionMap) return [];
|
|
3628
|
+
const partitionId = this.getPartitionId(key);
|
|
3629
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
3630
|
+
return partition?.backupNodeIds ?? [];
|
|
3631
|
+
}
|
|
3632
|
+
/**
|
|
3633
|
+
* Get the full partition map.
|
|
3634
|
+
* Returns null if not available.
|
|
3635
|
+
*/
|
|
3636
|
+
getMap() {
|
|
3637
|
+
return this.partitionMap;
|
|
3638
|
+
}
|
|
3639
|
+
/**
|
|
3640
|
+
* Update entire partition map.
|
|
3641
|
+
* Only accepts newer versions.
|
|
3642
|
+
*/
|
|
3643
|
+
updateMap(map) {
|
|
3644
|
+
if (this.partitionMap && map.version <= this.partitionMap.version) {
|
|
3645
|
+
return false;
|
|
3646
|
+
}
|
|
3647
|
+
this.partitionMap = map;
|
|
3648
|
+
this.lastRefreshTime = Date.now();
|
|
3649
|
+
this.updateConnectionPool(map);
|
|
3650
|
+
const changesCount = map.partitions.length;
|
|
3651
|
+
logger.info({
|
|
3652
|
+
version: map.version,
|
|
3653
|
+
partitions: map.partitionCount,
|
|
3654
|
+
nodes: map.nodes.length
|
|
3655
|
+
}, "Partition map updated via updateMap");
|
|
3656
|
+
this.emit("partitionMap:updated", map.version, changesCount);
|
|
3657
|
+
return true;
|
|
3658
|
+
}
|
|
3659
|
+
/**
|
|
3660
|
+
* Update a single partition (for delta updates).
|
|
3661
|
+
*/
|
|
3662
|
+
updatePartition(partitionId, owner, backups) {
|
|
3663
|
+
if (!this.partitionMap) return;
|
|
3664
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === partitionId);
|
|
3665
|
+
if (partition) {
|
|
3666
|
+
partition.ownerNodeId = owner;
|
|
3667
|
+
partition.backupNodeIds = backups;
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3670
|
+
/**
|
|
3671
|
+
* Check if partition map is stale
|
|
3672
|
+
*/
|
|
3673
|
+
isMapStale() {
|
|
3674
|
+
if (!this.partitionMap) return true;
|
|
3675
|
+
const now = Date.now();
|
|
3676
|
+
return now - this.lastRefreshTime > this.config.maxMapStalenessMs;
|
|
3677
|
+
}
|
|
3678
|
+
/**
|
|
3679
|
+
* Request fresh partition map from server
|
|
3680
|
+
*/
|
|
3681
|
+
async refreshPartitionMap() {
|
|
3682
|
+
if (this.pendingRefresh) {
|
|
3683
|
+
return this.pendingRefresh;
|
|
3684
|
+
}
|
|
3685
|
+
this.pendingRefresh = this.doRefreshPartitionMap();
|
|
3686
|
+
try {
|
|
3687
|
+
await this.pendingRefresh;
|
|
3688
|
+
} finally {
|
|
3689
|
+
this.pendingRefresh = null;
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3692
|
+
/**
|
|
3693
|
+
* Start periodic partition map refresh
|
|
3694
|
+
*/
|
|
3695
|
+
startPeriodicRefresh() {
|
|
3696
|
+
if (this.refreshTimer) return;
|
|
3697
|
+
this.refreshTimer = setInterval(() => {
|
|
3698
|
+
if (this.isMapStale()) {
|
|
3699
|
+
this.emit("partitionMap:stale", this.getMapVersion(), this.lastRefreshTime);
|
|
3700
|
+
this.refreshPartitionMap().catch((err) => {
|
|
3701
|
+
logger.error({ error: err }, "Failed to refresh partition map");
|
|
3702
|
+
});
|
|
3703
|
+
}
|
|
3704
|
+
}, this.config.mapRefreshIntervalMs);
|
|
3705
|
+
}
|
|
3706
|
+
/**
|
|
3707
|
+
* Stop periodic refresh
|
|
3708
|
+
*/
|
|
3709
|
+
stopPeriodicRefresh() {
|
|
3710
|
+
if (this.refreshTimer) {
|
|
3711
|
+
clearInterval(this.refreshTimer);
|
|
3712
|
+
this.refreshTimer = null;
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
3715
|
+
/**
|
|
3716
|
+
* Handle NOT_OWNER error from server
|
|
3717
|
+
*/
|
|
3718
|
+
handleNotOwnerError(key, actualOwner, newMapVersion) {
|
|
3719
|
+
const routing = this.route(key);
|
|
3720
|
+
const expectedOwner = routing?.nodeId ?? "unknown";
|
|
3721
|
+
this.emit("routing:miss", key, expectedOwner, actualOwner);
|
|
3722
|
+
if (newMapVersion > this.getMapVersion()) {
|
|
3723
|
+
this.refreshPartitionMap().catch((err) => {
|
|
3724
|
+
logger.error({ error: err }, "Failed to refresh partition map after NOT_OWNER");
|
|
3725
|
+
});
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
/**
|
|
3729
|
+
* Get statistics about routing
|
|
3730
|
+
*/
|
|
3731
|
+
getStats() {
|
|
3732
|
+
return {
|
|
3733
|
+
mapVersion: this.getMapVersion(),
|
|
3734
|
+
partitionCount: this.partitionMap?.partitionCount ?? 0,
|
|
3735
|
+
nodeCount: this.partitionMap?.nodes.length ?? 0,
|
|
3736
|
+
lastRefresh: this.lastRefreshTime,
|
|
3737
|
+
isStale: this.isMapStale()
|
|
3738
|
+
};
|
|
3739
|
+
}
|
|
3740
|
+
/**
|
|
3741
|
+
* Cleanup resources
|
|
3742
|
+
*/
|
|
3743
|
+
close() {
|
|
3744
|
+
this.stopPeriodicRefresh();
|
|
3745
|
+
this.partitionMap = null;
|
|
3746
|
+
}
|
|
3747
|
+
// ============================================
|
|
3748
|
+
// Private Methods
|
|
3749
|
+
// ============================================
|
|
3750
|
+
handlePartitionMap(message) {
|
|
3751
|
+
const newMap = message.payload;
|
|
3752
|
+
if (this.partitionMap && newMap.version <= this.partitionMap.version) {
|
|
3753
|
+
logger.debug({
|
|
3754
|
+
current: this.partitionMap.version,
|
|
3755
|
+
received: newMap.version
|
|
3756
|
+
}, "Ignoring older partition map");
|
|
3757
|
+
return;
|
|
3758
|
+
}
|
|
3759
|
+
this.partitionMap = newMap;
|
|
3760
|
+
this.lastRefreshTime = Date.now();
|
|
3761
|
+
this.updateConnectionPool(newMap);
|
|
3762
|
+
const changesCount = newMap.partitions.length;
|
|
3763
|
+
logger.info({
|
|
3764
|
+
version: newMap.version,
|
|
3765
|
+
partitions: newMap.partitionCount,
|
|
3766
|
+
nodes: newMap.nodes.length
|
|
3767
|
+
}, "Partition map updated");
|
|
3768
|
+
this.emit("partitionMap:updated", newMap.version, changesCount);
|
|
3769
|
+
}
|
|
3770
|
+
handlePartitionMapDelta(message) {
|
|
3771
|
+
const delta = message.payload;
|
|
3772
|
+
if (!this.partitionMap) {
|
|
3773
|
+
logger.warn("Received delta but no base map, requesting full map");
|
|
3774
|
+
this.refreshPartitionMap();
|
|
3775
|
+
return;
|
|
3776
|
+
}
|
|
3777
|
+
if (delta.previousVersion !== this.partitionMap.version) {
|
|
3778
|
+
logger.warn({
|
|
3779
|
+
expected: this.partitionMap.version,
|
|
3780
|
+
received: delta.previousVersion
|
|
3781
|
+
}, "Delta version mismatch, requesting full map");
|
|
3782
|
+
this.refreshPartitionMap();
|
|
3783
|
+
return;
|
|
3784
|
+
}
|
|
3785
|
+
for (const change of delta.changes) {
|
|
3786
|
+
this.applyPartitionChange(change);
|
|
3787
|
+
}
|
|
3788
|
+
this.partitionMap.version = delta.version;
|
|
3789
|
+
this.lastRefreshTime = Date.now();
|
|
3790
|
+
logger.info({
|
|
3791
|
+
version: delta.version,
|
|
3792
|
+
changes: delta.changes.length
|
|
3793
|
+
}, "Applied partition map delta");
|
|
3794
|
+
this.emit("partitionMap:updated", delta.version, delta.changes.length);
|
|
3795
|
+
}
|
|
3796
|
+
applyPartitionChange(change) {
|
|
3797
|
+
if (!this.partitionMap) return;
|
|
3798
|
+
const partition = this.partitionMap.partitions.find((p) => p.partitionId === change.partitionId);
|
|
3799
|
+
if (partition) {
|
|
3800
|
+
partition.ownerNodeId = change.newOwner;
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
updateConnectionPool(map) {
|
|
3804
|
+
for (const node of map.nodes) {
|
|
3805
|
+
if (node.status === "ACTIVE" || node.status === "JOINING") {
|
|
3806
|
+
this.connectionPool.addNode(node.nodeId, node.endpoints.websocket);
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
const currentNodeIds = new Set(map.nodes.map((n) => n.nodeId));
|
|
3810
|
+
for (const nodeId of this.connectionPool.getAllNodes()) {
|
|
3811
|
+
if (!currentNodeIds.has(nodeId)) {
|
|
3812
|
+
this.connectionPool.removeNode(nodeId);
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
async doRefreshPartitionMap() {
|
|
3817
|
+
logger.debug("Requesting partition map refresh");
|
|
3818
|
+
const sent = this.connectionPool.sendToPrimary({
|
|
3819
|
+
type: "PARTITION_MAP_REQUEST",
|
|
3820
|
+
payload: {
|
|
3821
|
+
currentVersion: this.getMapVersion()
|
|
3822
|
+
}
|
|
3823
|
+
});
|
|
3824
|
+
if (!sent) {
|
|
3825
|
+
throw new Error("No connection available to request partition map");
|
|
3826
|
+
}
|
|
3827
|
+
return new Promise((resolve, reject) => {
|
|
3828
|
+
const timeout = setTimeout(() => {
|
|
3829
|
+
this.removeListener("partitionMap:updated", onUpdate);
|
|
3830
|
+
reject(new Error("Partition map refresh timeout"));
|
|
3831
|
+
}, 5e3);
|
|
3832
|
+
const onUpdate = () => {
|
|
3833
|
+
clearTimeout(timeout);
|
|
3834
|
+
this.removeListener("partitionMap:updated", onUpdate);
|
|
3835
|
+
resolve();
|
|
3836
|
+
};
|
|
3837
|
+
this.once("partitionMap:updated", onUpdate);
|
|
3838
|
+
});
|
|
3839
|
+
}
|
|
3840
|
+
};
|
|
3841
|
+
|
|
3842
|
+
// src/cluster/ClusterClient.ts
|
|
3843
|
+
var ClusterClient = class {
|
|
3844
|
+
constructor(config) {
|
|
3845
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
3846
|
+
this.initialized = false;
|
|
3847
|
+
this.routingActive = false;
|
|
3848
|
+
this.routingMetrics = {
|
|
3849
|
+
directRoutes: 0,
|
|
3850
|
+
fallbackRoutes: 0,
|
|
3851
|
+
partitionMisses: 0,
|
|
3852
|
+
totalRoutes: 0
|
|
3853
|
+
};
|
|
3854
|
+
// Circuit breaker state per node
|
|
3855
|
+
this.circuits = /* @__PURE__ */ new Map();
|
|
3856
|
+
this.config = config;
|
|
3857
|
+
this.circuitBreakerConfig = {
|
|
3858
|
+
...DEFAULT_CIRCUIT_BREAKER_CONFIG,
|
|
3859
|
+
...config.circuitBreaker
|
|
3860
|
+
};
|
|
3861
|
+
const poolConfig = {
|
|
3862
|
+
...DEFAULT_CONNECTION_POOL_CONFIG2,
|
|
3863
|
+
...config.connectionPool
|
|
3864
|
+
};
|
|
3865
|
+
this.connectionPool = new ConnectionPool(poolConfig);
|
|
3866
|
+
const routerConfig = {
|
|
3867
|
+
...DEFAULT_PARTITION_ROUTER_CONFIG2,
|
|
3868
|
+
fallbackMode: config.routingMode === "direct" ? "error" : "forward",
|
|
3869
|
+
...config.routing
|
|
3870
|
+
};
|
|
3871
|
+
this.partitionRouter = new PartitionRouter(this.connectionPool, routerConfig);
|
|
3872
|
+
this.setupEventHandlers();
|
|
3873
|
+
}
|
|
3874
|
+
// ============================================
|
|
3875
|
+
// Event Emitter Methods (browser-compatible)
|
|
3876
|
+
// ============================================
|
|
3877
|
+
on(event, listener) {
|
|
3878
|
+
if (!this.listeners.has(event)) {
|
|
3879
|
+
this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
3880
|
+
}
|
|
3881
|
+
this.listeners.get(event).add(listener);
|
|
3882
|
+
return this;
|
|
3883
|
+
}
|
|
3884
|
+
off(event, listener) {
|
|
3885
|
+
this.listeners.get(event)?.delete(listener);
|
|
3886
|
+
return this;
|
|
3887
|
+
}
|
|
3888
|
+
emit(event, ...args) {
|
|
3889
|
+
const eventListeners = this.listeners.get(event);
|
|
3890
|
+
if (!eventListeners || eventListeners.size === 0) {
|
|
3891
|
+
return false;
|
|
3892
|
+
}
|
|
3893
|
+
for (const listener of eventListeners) {
|
|
3894
|
+
try {
|
|
3895
|
+
listener(...args);
|
|
3896
|
+
} catch (err) {
|
|
3897
|
+
logger.error({ event, err }, "Error in event listener");
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
return true;
|
|
3901
|
+
}
|
|
3902
|
+
removeAllListeners(event) {
|
|
3903
|
+
if (event) {
|
|
3904
|
+
this.listeners.delete(event);
|
|
3905
|
+
} else {
|
|
3906
|
+
this.listeners.clear();
|
|
3907
|
+
}
|
|
3908
|
+
return this;
|
|
3909
|
+
}
|
|
3910
|
+
// ============================================
|
|
3911
|
+
// IConnectionProvider Implementation
|
|
3912
|
+
// ============================================
|
|
3913
|
+
/**
|
|
3914
|
+
* Connect to cluster nodes (IConnectionProvider interface).
|
|
3915
|
+
* Alias for start() method.
|
|
3916
|
+
*/
|
|
3917
|
+
async connect() {
|
|
3918
|
+
return this.start();
|
|
3919
|
+
}
|
|
3920
|
+
/**
|
|
3921
|
+
* Get connection for a specific key (IConnectionProvider interface).
|
|
3922
|
+
* Routes to partition owner based on key hash when smart routing is enabled.
|
|
3923
|
+
* @throws Error if not connected
|
|
3924
|
+
*/
|
|
3925
|
+
getConnection(key) {
|
|
3926
|
+
if (!this.isConnected()) {
|
|
3927
|
+
throw new Error("ClusterClient not connected");
|
|
3928
|
+
}
|
|
3929
|
+
this.routingMetrics.totalRoutes++;
|
|
3930
|
+
if (this.config.routingMode !== "direct" || !this.routingActive) {
|
|
3931
|
+
this.routingMetrics.fallbackRoutes++;
|
|
3932
|
+
return this.getFallbackConnection();
|
|
3933
|
+
}
|
|
3934
|
+
const routing = this.partitionRouter.route(key);
|
|
3935
|
+
if (!routing) {
|
|
3936
|
+
this.routingMetrics.partitionMisses++;
|
|
3937
|
+
logger.debug({ key }, "No partition map available, using fallback");
|
|
3938
|
+
return this.getFallbackConnection();
|
|
3939
|
+
}
|
|
3940
|
+
const owner = routing.nodeId;
|
|
3941
|
+
if (!this.connectionPool.isNodeConnected(owner)) {
|
|
3942
|
+
this.routingMetrics.fallbackRoutes++;
|
|
3943
|
+
logger.debug({ key, owner }, "Partition owner not connected, using fallback");
|
|
3944
|
+
this.requestPartitionMapRefresh();
|
|
3945
|
+
return this.getFallbackConnection();
|
|
3946
|
+
}
|
|
3947
|
+
const socket = this.connectionPool.getConnection(owner);
|
|
3948
|
+
if (!socket) {
|
|
3949
|
+
this.routingMetrics.fallbackRoutes++;
|
|
3950
|
+
logger.debug({ key, owner }, "Could not get connection to owner, using fallback");
|
|
3951
|
+
return this.getFallbackConnection();
|
|
3952
|
+
}
|
|
3953
|
+
this.routingMetrics.directRoutes++;
|
|
3954
|
+
return socket;
|
|
3955
|
+
}
|
|
3956
|
+
/**
|
|
3957
|
+
* Get fallback connection when owner is unavailable.
|
|
3958
|
+
* @throws Error if no connection available
|
|
3959
|
+
*/
|
|
3960
|
+
getFallbackConnection() {
|
|
3961
|
+
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
3962
|
+
if (!conn?.socket) {
|
|
3963
|
+
throw new Error("No healthy connection available");
|
|
3964
|
+
}
|
|
3965
|
+
return conn.socket;
|
|
3966
|
+
}
|
|
3967
|
+
/**
|
|
3968
|
+
* Request a partition map refresh in the background.
|
|
3969
|
+
* Called when routing to an unknown/disconnected owner.
|
|
3970
|
+
*/
|
|
3971
|
+
requestPartitionMapRefresh() {
|
|
3972
|
+
this.partitionRouter.refreshPartitionMap().catch((err) => {
|
|
3973
|
+
logger.error({ err }, "Failed to refresh partition map");
|
|
3974
|
+
});
|
|
3975
|
+
}
|
|
3976
|
+
/**
|
|
3977
|
+
* Request partition map from a specific node.
|
|
3978
|
+
* Called on first node connection.
|
|
3979
|
+
*/
|
|
3980
|
+
requestPartitionMapFromNode(nodeId) {
|
|
3981
|
+
const socket = this.connectionPool.getConnection(nodeId);
|
|
3982
|
+
if (socket) {
|
|
3983
|
+
logger.debug({ nodeId }, "Requesting partition map from node");
|
|
3984
|
+
socket.send(serialize3({
|
|
3985
|
+
type: "PARTITION_MAP_REQUEST",
|
|
3986
|
+
payload: {
|
|
3987
|
+
currentVersion: this.partitionRouter.getMapVersion()
|
|
3988
|
+
}
|
|
3989
|
+
}));
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
/**
|
|
3993
|
+
* Check if at least one connection is active (IConnectionProvider interface).
|
|
3994
|
+
*/
|
|
3995
|
+
isConnected() {
|
|
3996
|
+
return this.connectionPool.getConnectedNodes().length > 0;
|
|
3997
|
+
}
|
|
3998
|
+
/**
|
|
3999
|
+
* Send data via the appropriate connection (IConnectionProvider interface).
|
|
4000
|
+
* Routes based on key if provided.
|
|
4001
|
+
*/
|
|
4002
|
+
send(data, key) {
|
|
4003
|
+
if (!this.isConnected()) {
|
|
4004
|
+
throw new Error("ClusterClient not connected");
|
|
4005
|
+
}
|
|
4006
|
+
const socket = key ? this.getConnection(key) : this.getAnyConnection();
|
|
4007
|
+
socket.send(data);
|
|
4008
|
+
}
|
|
4009
|
+
/**
|
|
4010
|
+
* Send data with automatic retry and rerouting on failure.
|
|
4011
|
+
* @param data - Data to send
|
|
4012
|
+
* @param key - Optional key for routing
|
|
4013
|
+
* @param options - Retry options
|
|
4014
|
+
* @throws Error after max retries exceeded
|
|
4015
|
+
*/
|
|
4016
|
+
async sendWithRetry(data, key, options = {}) {
|
|
4017
|
+
const {
|
|
4018
|
+
maxRetries = 3,
|
|
4019
|
+
retryDelayMs = 100,
|
|
4020
|
+
retryOnNotOwner = true
|
|
4021
|
+
} = options;
|
|
4022
|
+
let lastError = null;
|
|
4023
|
+
let nodeId = null;
|
|
4024
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
4025
|
+
try {
|
|
4026
|
+
if (key && this.routingActive) {
|
|
4027
|
+
const routing = this.partitionRouter.route(key);
|
|
4028
|
+
nodeId = routing?.nodeId ?? null;
|
|
4029
|
+
}
|
|
4030
|
+
if (nodeId && !this.canUseNode(nodeId)) {
|
|
4031
|
+
logger.debug({ nodeId, attempt }, "Circuit open, using fallback");
|
|
4032
|
+
nodeId = null;
|
|
4033
|
+
}
|
|
4034
|
+
const socket = key && nodeId ? this.connectionPool.getConnection(nodeId) : this.getAnyConnection();
|
|
4035
|
+
if (!socket) {
|
|
4036
|
+
throw new Error("No connection available");
|
|
4037
|
+
}
|
|
4038
|
+
socket.send(data);
|
|
4039
|
+
if (nodeId) {
|
|
4040
|
+
this.recordSuccess(nodeId);
|
|
4041
|
+
}
|
|
4042
|
+
return;
|
|
4043
|
+
} catch (error) {
|
|
4044
|
+
lastError = error;
|
|
4045
|
+
if (nodeId) {
|
|
4046
|
+
this.recordFailure(nodeId);
|
|
4047
|
+
}
|
|
4048
|
+
const errorCode = error?.code;
|
|
4049
|
+
if (this.isRetryableError(error)) {
|
|
4050
|
+
logger.debug(
|
|
4051
|
+
{ attempt, maxRetries, errorCode, nodeId },
|
|
4052
|
+
"Retryable error, will retry"
|
|
4053
|
+
);
|
|
4054
|
+
if (errorCode === "NOT_OWNER" && retryOnNotOwner) {
|
|
4055
|
+
await this.waitForPartitionMapUpdateInternal(2e3);
|
|
4056
|
+
} else if (errorCode === "CONNECTION_CLOSED" || !this.isConnected()) {
|
|
4057
|
+
await this.waitForConnectionInternal(5e3);
|
|
4058
|
+
}
|
|
4059
|
+
await this.delay(retryDelayMs * (attempt + 1));
|
|
4060
|
+
continue;
|
|
4061
|
+
}
|
|
4062
|
+
throw error;
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
throw new Error(
|
|
4066
|
+
`Operation failed after ${maxRetries} retries: ${lastError?.message}`
|
|
4067
|
+
);
|
|
4068
|
+
}
|
|
4069
|
+
/**
|
|
4070
|
+
* Check if an error is retryable.
|
|
4071
|
+
*/
|
|
4072
|
+
isRetryableError(error) {
|
|
4073
|
+
const code = error?.code;
|
|
4074
|
+
const message = error?.message || "";
|
|
4075
|
+
return code === "NOT_OWNER" || code === "CONNECTION_CLOSED" || code === "TIMEOUT" || code === "ECONNRESET" || message.includes("No active connections") || message.includes("No connection available") || message.includes("No healthy connection");
|
|
4076
|
+
}
|
|
4077
|
+
/**
|
|
4078
|
+
* Wait for partition map update.
|
|
4079
|
+
*/
|
|
4080
|
+
waitForPartitionMapUpdateInternal(timeoutMs) {
|
|
4081
|
+
return new Promise((resolve) => {
|
|
4082
|
+
const timeout = setTimeout(resolve, timeoutMs);
|
|
4083
|
+
const handler2 = () => {
|
|
4084
|
+
clearTimeout(timeout);
|
|
4085
|
+
this.off("partitionMapUpdated", handler2);
|
|
4086
|
+
resolve();
|
|
4087
|
+
};
|
|
4088
|
+
this.on("partitionMapUpdated", handler2);
|
|
4089
|
+
});
|
|
4090
|
+
}
|
|
4091
|
+
/**
|
|
4092
|
+
* Wait for at least one connection to be available.
|
|
4093
|
+
*/
|
|
4094
|
+
waitForConnectionInternal(timeoutMs) {
|
|
4095
|
+
return new Promise((resolve, reject) => {
|
|
4096
|
+
if (this.isConnected()) {
|
|
4097
|
+
resolve();
|
|
4098
|
+
return;
|
|
4099
|
+
}
|
|
4100
|
+
const timeout = setTimeout(() => {
|
|
4101
|
+
this.off("connected", handler2);
|
|
4102
|
+
reject(new Error("Connection timeout"));
|
|
4103
|
+
}, timeoutMs);
|
|
4104
|
+
const handler2 = () => {
|
|
4105
|
+
clearTimeout(timeout);
|
|
4106
|
+
this.off("connected", handler2);
|
|
4107
|
+
resolve();
|
|
4108
|
+
};
|
|
4109
|
+
this.on("connected", handler2);
|
|
4110
|
+
});
|
|
4111
|
+
}
|
|
4112
|
+
/**
|
|
4113
|
+
* Helper delay function.
|
|
4114
|
+
*/
|
|
4115
|
+
delay(ms) {
|
|
4116
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4117
|
+
}
|
|
4118
|
+
// ============================================
|
|
4119
|
+
// Cluster-Specific Methods
|
|
4120
|
+
// ============================================
|
|
4121
|
+
/**
|
|
4122
|
+
* Initialize cluster connections
|
|
4123
|
+
*/
|
|
4124
|
+
async start() {
|
|
4125
|
+
if (this.initialized) return;
|
|
4126
|
+
logger.info({ seedNodes: this.config.seedNodes }, "Starting cluster client");
|
|
4127
|
+
for (let i = 0; i < this.config.seedNodes.length; i++) {
|
|
4128
|
+
const endpoint = this.config.seedNodes[i];
|
|
4129
|
+
const nodeId = `seed-${i}`;
|
|
4130
|
+
await this.connectionPool.addNode(nodeId, endpoint);
|
|
4131
|
+
}
|
|
4132
|
+
this.connectionPool.startHealthCheck();
|
|
4133
|
+
this.partitionRouter.startPeriodicRefresh();
|
|
4134
|
+
this.initialized = true;
|
|
4135
|
+
await this.waitForPartitionMap();
|
|
4136
|
+
}
|
|
4137
|
+
/**
|
|
4138
|
+
* Set authentication token
|
|
4139
|
+
*/
|
|
4140
|
+
setAuthToken(token) {
|
|
4141
|
+
this.connectionPool.setAuthToken(token);
|
|
4142
|
+
}
|
|
4143
|
+
/**
|
|
4144
|
+
* Send operation with automatic routing (legacy API for cluster operations).
|
|
4145
|
+
* @deprecated Use send(data, key) for IConnectionProvider interface
|
|
4146
|
+
*/
|
|
4147
|
+
sendMessage(key, message) {
|
|
4148
|
+
if (this.config.routingMode === "direct" && this.routingActive) {
|
|
4149
|
+
return this.sendDirect(key, message);
|
|
4150
|
+
}
|
|
4151
|
+
return this.sendForward(message);
|
|
4152
|
+
}
|
|
4153
|
+
/**
|
|
4154
|
+
* Send directly to partition owner
|
|
4155
|
+
*/
|
|
4156
|
+
sendDirect(key, message) {
|
|
4157
|
+
const connection = this.partitionRouter.routeToConnection(key);
|
|
4158
|
+
if (!connection) {
|
|
4159
|
+
logger.warn({ key }, "No route available for key");
|
|
4160
|
+
return false;
|
|
4161
|
+
}
|
|
4162
|
+
const routedMessage = {
|
|
4163
|
+
...message,
|
|
4164
|
+
_routing: {
|
|
4165
|
+
partitionId: this.partitionRouter.getPartitionId(key),
|
|
4166
|
+
mapVersion: this.partitionRouter.getMapVersion()
|
|
4167
|
+
}
|
|
4168
|
+
};
|
|
4169
|
+
connection.socket.send(serialize3(routedMessage));
|
|
4170
|
+
return true;
|
|
4171
|
+
}
|
|
4172
|
+
/**
|
|
4173
|
+
* Send to primary node for server-side forwarding
|
|
4174
|
+
*/
|
|
4175
|
+
sendForward(message) {
|
|
4176
|
+
return this.connectionPool.sendToPrimary(message);
|
|
4177
|
+
}
|
|
4178
|
+
/**
|
|
4179
|
+
* Send batch of operations with routing
|
|
4180
|
+
*/
|
|
4181
|
+
sendBatch(operations) {
|
|
4182
|
+
const results = /* @__PURE__ */ new Map();
|
|
4183
|
+
if (this.config.routingMode === "direct" && this.routingActive) {
|
|
4184
|
+
const nodeMessages = /* @__PURE__ */ new Map();
|
|
4185
|
+
for (const { key, message } of operations) {
|
|
4186
|
+
const routing = this.partitionRouter.route(key);
|
|
4187
|
+
const nodeId = routing?.nodeId ?? "primary";
|
|
4188
|
+
if (!nodeMessages.has(nodeId)) {
|
|
4189
|
+
nodeMessages.set(nodeId, []);
|
|
4190
|
+
}
|
|
4191
|
+
nodeMessages.get(nodeId).push({ key, message });
|
|
4192
|
+
}
|
|
4193
|
+
for (const [nodeId, messages] of nodeMessages) {
|
|
4194
|
+
let success;
|
|
4195
|
+
if (nodeId === "primary") {
|
|
4196
|
+
success = this.connectionPool.sendToPrimary({
|
|
4197
|
+
type: "OP_BATCH",
|
|
4198
|
+
payload: { ops: messages.map((m) => m.message) }
|
|
4199
|
+
});
|
|
4200
|
+
} else {
|
|
4201
|
+
success = this.connectionPool.send(nodeId, {
|
|
4202
|
+
type: "OP_BATCH",
|
|
4203
|
+
payload: { ops: messages.map((m) => m.message) }
|
|
4204
|
+
});
|
|
4205
|
+
}
|
|
4206
|
+
for (const { key } of messages) {
|
|
4207
|
+
results.set(key, success);
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4210
|
+
} else {
|
|
4211
|
+
const success = this.connectionPool.sendToPrimary({
|
|
4212
|
+
type: "OP_BATCH",
|
|
4213
|
+
payload: { ops: operations.map((o) => o.message) }
|
|
1491
4214
|
});
|
|
4215
|
+
for (const { key } of operations) {
|
|
4216
|
+
results.set(key, success);
|
|
4217
|
+
}
|
|
1492
4218
|
}
|
|
1493
4219
|
return results;
|
|
1494
4220
|
}
|
|
1495
|
-
|
|
1496
|
-
|
|
4221
|
+
/**
|
|
4222
|
+
* Get connection pool health status
|
|
4223
|
+
*/
|
|
4224
|
+
getHealthStatus() {
|
|
4225
|
+
return this.connectionPool.getHealthStatus();
|
|
1497
4226
|
}
|
|
1498
|
-
|
|
1499
|
-
|
|
4227
|
+
/**
|
|
4228
|
+
* Get partition router stats
|
|
4229
|
+
*/
|
|
4230
|
+
getRouterStats() {
|
|
4231
|
+
return this.partitionRouter.getStats();
|
|
1500
4232
|
}
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
this.fencingToken = null;
|
|
1507
|
-
this._isLocked = false;
|
|
1508
|
-
this.syncEngine = syncEngine;
|
|
1509
|
-
this.name = name;
|
|
4233
|
+
/**
|
|
4234
|
+
* Get routing metrics for monitoring smart routing effectiveness.
|
|
4235
|
+
*/
|
|
4236
|
+
getRoutingMetrics() {
|
|
4237
|
+
return { ...this.routingMetrics };
|
|
1510
4238
|
}
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
}
|
|
4239
|
+
/**
|
|
4240
|
+
* Reset routing metrics counters.
|
|
4241
|
+
* Useful for monitoring intervals.
|
|
4242
|
+
*/
|
|
4243
|
+
resetRoutingMetrics() {
|
|
4244
|
+
this.routingMetrics.directRoutes = 0;
|
|
4245
|
+
this.routingMetrics.fallbackRoutes = 0;
|
|
4246
|
+
this.routingMetrics.partitionMisses = 0;
|
|
4247
|
+
this.routingMetrics.totalRoutes = 0;
|
|
1521
4248
|
}
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
} finally {
|
|
1528
|
-
this._isLocked = false;
|
|
1529
|
-
this.fencingToken = null;
|
|
1530
|
-
}
|
|
4249
|
+
/**
|
|
4250
|
+
* Check if cluster routing is active
|
|
4251
|
+
*/
|
|
4252
|
+
isRoutingActive() {
|
|
4253
|
+
return this.routingActive;
|
|
1531
4254
|
}
|
|
1532
|
-
|
|
1533
|
-
|
|
4255
|
+
/**
|
|
4256
|
+
* Get list of connected nodes
|
|
4257
|
+
*/
|
|
4258
|
+
getConnectedNodes() {
|
|
4259
|
+
return this.connectionPool.getConnectedNodes();
|
|
1534
4260
|
}
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
this.listeners = /* @__PURE__ */ new Set();
|
|
1541
|
-
this.engine = engine;
|
|
1542
|
-
this.topic = topic;
|
|
4261
|
+
/**
|
|
4262
|
+
* Check if cluster client is initialized
|
|
4263
|
+
*/
|
|
4264
|
+
isInitialized() {
|
|
4265
|
+
return this.initialized;
|
|
1543
4266
|
}
|
|
1544
|
-
|
|
1545
|
-
|
|
4267
|
+
/**
|
|
4268
|
+
* Force refresh of partition map
|
|
4269
|
+
*/
|
|
4270
|
+
async refreshPartitionMap() {
|
|
4271
|
+
await this.partitionRouter.refreshPartitionMap();
|
|
1546
4272
|
}
|
|
1547
4273
|
/**
|
|
1548
|
-
*
|
|
4274
|
+
* Shutdown cluster client (IConnectionProvider interface).
|
|
1549
4275
|
*/
|
|
1550
|
-
|
|
1551
|
-
this.
|
|
4276
|
+
async close() {
|
|
4277
|
+
this.partitionRouter.close();
|
|
4278
|
+
this.connectionPool.close();
|
|
4279
|
+
this.initialized = false;
|
|
4280
|
+
this.routingActive = false;
|
|
4281
|
+
logger.info("Cluster client closed");
|
|
4282
|
+
}
|
|
4283
|
+
// ============================================
|
|
4284
|
+
// Internal Access for TopGunClient
|
|
4285
|
+
// ============================================
|
|
4286
|
+
/**
|
|
4287
|
+
* Get the connection pool (for internal use)
|
|
4288
|
+
*/
|
|
4289
|
+
getConnectionPool() {
|
|
4290
|
+
return this.connectionPool;
|
|
1552
4291
|
}
|
|
1553
4292
|
/**
|
|
1554
|
-
*
|
|
4293
|
+
* Get the partition router (for internal use)
|
|
1555
4294
|
*/
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
4295
|
+
getPartitionRouter() {
|
|
4296
|
+
return this.partitionRouter;
|
|
4297
|
+
}
|
|
4298
|
+
/**
|
|
4299
|
+
* Get any healthy WebSocket connection (IConnectionProvider interface).
|
|
4300
|
+
* @throws Error if not connected
|
|
4301
|
+
*/
|
|
4302
|
+
getAnyConnection() {
|
|
4303
|
+
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
4304
|
+
if (!conn?.socket) {
|
|
4305
|
+
throw new Error("No healthy connection available");
|
|
1559
4306
|
}
|
|
1560
|
-
|
|
1561
|
-
return () => this.unsubscribe(callback);
|
|
4307
|
+
return conn.socket;
|
|
1562
4308
|
}
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
4309
|
+
/**
|
|
4310
|
+
* Get any healthy WebSocket connection, or null if none available.
|
|
4311
|
+
* Use this for optional connection checks.
|
|
4312
|
+
*/
|
|
4313
|
+
getAnyConnectionOrNull() {
|
|
4314
|
+
const conn = this.connectionPool.getAnyHealthyConnection();
|
|
4315
|
+
return conn?.socket ?? null;
|
|
4316
|
+
}
|
|
4317
|
+
// ============================================
|
|
4318
|
+
// Circuit Breaker Methods
|
|
4319
|
+
// ============================================
|
|
4320
|
+
/**
|
|
4321
|
+
* Get circuit breaker state for a node.
|
|
4322
|
+
*/
|
|
4323
|
+
getCircuit(nodeId) {
|
|
4324
|
+
let circuit = this.circuits.get(nodeId);
|
|
4325
|
+
if (!circuit) {
|
|
4326
|
+
circuit = { failures: 0, lastFailure: 0, state: "closed" };
|
|
4327
|
+
this.circuits.set(nodeId, circuit);
|
|
1567
4328
|
}
|
|
4329
|
+
return circuit;
|
|
1568
4330
|
}
|
|
1569
4331
|
/**
|
|
1570
|
-
*
|
|
4332
|
+
* Check if a node can be used (circuit not open).
|
|
1571
4333
|
*/
|
|
1572
|
-
|
|
1573
|
-
this.
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
4334
|
+
canUseNode(nodeId) {
|
|
4335
|
+
const circuit = this.getCircuit(nodeId);
|
|
4336
|
+
if (circuit.state === "closed") {
|
|
4337
|
+
return true;
|
|
4338
|
+
}
|
|
4339
|
+
if (circuit.state === "open") {
|
|
4340
|
+
if (Date.now() - circuit.lastFailure > this.circuitBreakerConfig.resetTimeoutMs) {
|
|
4341
|
+
circuit.state = "half-open";
|
|
4342
|
+
logger.debug({ nodeId }, "Circuit breaker half-open, allowing test request");
|
|
4343
|
+
this.emit("circuit:half-open", nodeId);
|
|
4344
|
+
return true;
|
|
4345
|
+
}
|
|
4346
|
+
return false;
|
|
4347
|
+
}
|
|
4348
|
+
return true;
|
|
4349
|
+
}
|
|
4350
|
+
/**
|
|
4351
|
+
* Record a successful operation to a node.
|
|
4352
|
+
* Resets circuit breaker on success.
|
|
4353
|
+
*/
|
|
4354
|
+
recordSuccess(nodeId) {
|
|
4355
|
+
const circuit = this.getCircuit(nodeId);
|
|
4356
|
+
const wasOpen = circuit.state !== "closed";
|
|
4357
|
+
circuit.failures = 0;
|
|
4358
|
+
circuit.state = "closed";
|
|
4359
|
+
if (wasOpen) {
|
|
4360
|
+
logger.info({ nodeId }, "Circuit breaker closed after success");
|
|
4361
|
+
this.emit("circuit:closed", nodeId);
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
/**
|
|
4365
|
+
* Record a failed operation to a node.
|
|
4366
|
+
* Opens circuit breaker after threshold failures.
|
|
4367
|
+
*/
|
|
4368
|
+
recordFailure(nodeId) {
|
|
4369
|
+
const circuit = this.getCircuit(nodeId);
|
|
4370
|
+
circuit.failures++;
|
|
4371
|
+
circuit.lastFailure = Date.now();
|
|
4372
|
+
if (circuit.failures >= this.circuitBreakerConfig.failureThreshold) {
|
|
4373
|
+
if (circuit.state !== "open") {
|
|
4374
|
+
circuit.state = "open";
|
|
4375
|
+
logger.warn({ nodeId, failures: circuit.failures }, "Circuit breaker opened");
|
|
4376
|
+
this.emit("circuit:open", nodeId);
|
|
4377
|
+
}
|
|
4378
|
+
}
|
|
4379
|
+
}
|
|
4380
|
+
/**
|
|
4381
|
+
* Get all circuit breaker states.
|
|
4382
|
+
*/
|
|
4383
|
+
getCircuitStates() {
|
|
4384
|
+
return new Map(this.circuits);
|
|
4385
|
+
}
|
|
4386
|
+
/**
|
|
4387
|
+
* Reset circuit breaker for a specific node.
|
|
4388
|
+
*/
|
|
4389
|
+
resetCircuit(nodeId) {
|
|
4390
|
+
this.circuits.delete(nodeId);
|
|
4391
|
+
logger.debug({ nodeId }, "Circuit breaker reset");
|
|
4392
|
+
}
|
|
4393
|
+
/**
|
|
4394
|
+
* Reset all circuit breakers.
|
|
4395
|
+
*/
|
|
4396
|
+
resetAllCircuits() {
|
|
4397
|
+
this.circuits.clear();
|
|
4398
|
+
logger.debug("All circuit breakers reset");
|
|
4399
|
+
}
|
|
4400
|
+
// ============================================
|
|
4401
|
+
// Private Methods
|
|
4402
|
+
// ============================================
|
|
4403
|
+
setupEventHandlers() {
|
|
4404
|
+
this.connectionPool.on("node:connected", (nodeId) => {
|
|
4405
|
+
logger.debug({ nodeId }, "Node connected");
|
|
4406
|
+
if (this.partitionRouter.getMapVersion() === 0) {
|
|
4407
|
+
this.requestPartitionMapFromNode(nodeId);
|
|
4408
|
+
}
|
|
4409
|
+
if (this.connectionPool.getConnectedNodes().length === 1) {
|
|
4410
|
+
this.emit("connected");
|
|
4411
|
+
}
|
|
4412
|
+
});
|
|
4413
|
+
this.connectionPool.on("node:disconnected", (nodeId, reason) => {
|
|
4414
|
+
logger.debug({ nodeId, reason }, "Node disconnected");
|
|
4415
|
+
if (this.connectionPool.getConnectedNodes().length === 0) {
|
|
4416
|
+
this.routingActive = false;
|
|
4417
|
+
this.emit("disconnected", reason);
|
|
4418
|
+
}
|
|
4419
|
+
});
|
|
4420
|
+
this.connectionPool.on("node:unhealthy", (nodeId, reason) => {
|
|
4421
|
+
logger.warn({ nodeId, reason }, "Node unhealthy");
|
|
4422
|
+
});
|
|
4423
|
+
this.connectionPool.on("error", (nodeId, error) => {
|
|
4424
|
+
this.emit("error", error);
|
|
4425
|
+
});
|
|
4426
|
+
this.connectionPool.on("message", (nodeId, data) => {
|
|
4427
|
+
this.emit("message", nodeId, data);
|
|
4428
|
+
});
|
|
4429
|
+
this.partitionRouter.on("partitionMap:updated", (version, changesCount) => {
|
|
4430
|
+
if (!this.routingActive && this.partitionRouter.hasPartitionMap()) {
|
|
4431
|
+
this.routingActive = true;
|
|
4432
|
+
logger.info({ version }, "Direct routing activated");
|
|
4433
|
+
this.emit("routing:active");
|
|
1578
4434
|
}
|
|
4435
|
+
this.emit("partitionMap:ready", version);
|
|
4436
|
+
this.emit("partitionMapUpdated");
|
|
4437
|
+
});
|
|
4438
|
+
this.partitionRouter.on("routing:miss", (key, expected, actual) => {
|
|
4439
|
+
logger.debug({ key, expected, actual }, "Routing miss detected");
|
|
4440
|
+
});
|
|
4441
|
+
}
|
|
4442
|
+
async waitForPartitionMap(timeoutMs = 1e4) {
|
|
4443
|
+
if (this.partitionRouter.hasPartitionMap()) {
|
|
4444
|
+
this.routingActive = true;
|
|
4445
|
+
return;
|
|
4446
|
+
}
|
|
4447
|
+
return new Promise((resolve) => {
|
|
4448
|
+
const timeout = setTimeout(() => {
|
|
4449
|
+
this.partitionRouter.off("partitionMap:updated", onUpdate);
|
|
4450
|
+
logger.warn("Partition map not received, using fallback routing");
|
|
4451
|
+
resolve();
|
|
4452
|
+
}, timeoutMs);
|
|
4453
|
+
const onUpdate = () => {
|
|
4454
|
+
clearTimeout(timeout);
|
|
4455
|
+
this.partitionRouter.off("partitionMap:updated", onUpdate);
|
|
4456
|
+
this.routingActive = true;
|
|
4457
|
+
resolve();
|
|
4458
|
+
};
|
|
4459
|
+
this.partitionRouter.once("partitionMap:updated", onUpdate);
|
|
1579
4460
|
});
|
|
1580
4461
|
}
|
|
1581
4462
|
};
|
|
1582
4463
|
|
|
1583
4464
|
// src/TopGunClient.ts
|
|
4465
|
+
var DEFAULT_CLUSTER_CONFIG = {
|
|
4466
|
+
connectionsPerNode: 1,
|
|
4467
|
+
smartRouting: true,
|
|
4468
|
+
partitionMapRefreshMs: 3e4,
|
|
4469
|
+
connectionTimeoutMs: 5e3,
|
|
4470
|
+
retryAttempts: 3
|
|
4471
|
+
};
|
|
1584
4472
|
var TopGunClient = class {
|
|
1585
4473
|
constructor(config) {
|
|
1586
4474
|
this.maps = /* @__PURE__ */ new Map();
|
|
1587
4475
|
this.topicHandles = /* @__PURE__ */ new Map();
|
|
4476
|
+
this.counters = /* @__PURE__ */ new Map();
|
|
4477
|
+
if (config.serverUrl && config.cluster) {
|
|
4478
|
+
throw new Error("Cannot specify both serverUrl and cluster config");
|
|
4479
|
+
}
|
|
4480
|
+
if (!config.serverUrl && !config.cluster) {
|
|
4481
|
+
throw new Error("Must specify either serverUrl or cluster config");
|
|
4482
|
+
}
|
|
1588
4483
|
this.nodeId = config.nodeId || crypto.randomUUID();
|
|
1589
4484
|
this.storageAdapter = config.storage;
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
4485
|
+
this.isClusterMode = !!config.cluster;
|
|
4486
|
+
if (config.cluster) {
|
|
4487
|
+
if (!config.cluster.seeds || config.cluster.seeds.length === 0) {
|
|
4488
|
+
throw new Error("Cluster config requires at least one seed node");
|
|
4489
|
+
}
|
|
4490
|
+
this.clusterConfig = {
|
|
4491
|
+
seeds: config.cluster.seeds,
|
|
4492
|
+
connectionsPerNode: config.cluster.connectionsPerNode ?? DEFAULT_CLUSTER_CONFIG.connectionsPerNode,
|
|
4493
|
+
smartRouting: config.cluster.smartRouting ?? DEFAULT_CLUSTER_CONFIG.smartRouting,
|
|
4494
|
+
partitionMapRefreshMs: config.cluster.partitionMapRefreshMs ?? DEFAULT_CLUSTER_CONFIG.partitionMapRefreshMs,
|
|
4495
|
+
connectionTimeoutMs: config.cluster.connectionTimeoutMs ?? DEFAULT_CLUSTER_CONFIG.connectionTimeoutMs,
|
|
4496
|
+
retryAttempts: config.cluster.retryAttempts ?? DEFAULT_CLUSTER_CONFIG.retryAttempts
|
|
4497
|
+
};
|
|
4498
|
+
this.clusterClient = new ClusterClient({
|
|
4499
|
+
enabled: true,
|
|
4500
|
+
seedNodes: this.clusterConfig.seeds,
|
|
4501
|
+
routingMode: this.clusterConfig.smartRouting ? "direct" : "forward",
|
|
4502
|
+
connectionPool: {
|
|
4503
|
+
maxConnectionsPerNode: this.clusterConfig.connectionsPerNode,
|
|
4504
|
+
connectionTimeoutMs: this.clusterConfig.connectionTimeoutMs
|
|
4505
|
+
},
|
|
4506
|
+
routing: {
|
|
4507
|
+
mapRefreshIntervalMs: this.clusterConfig.partitionMapRefreshMs
|
|
4508
|
+
}
|
|
4509
|
+
});
|
|
4510
|
+
this.syncEngine = new SyncEngine({
|
|
4511
|
+
nodeId: this.nodeId,
|
|
4512
|
+
connectionProvider: this.clusterClient,
|
|
4513
|
+
storageAdapter: this.storageAdapter,
|
|
4514
|
+
backoff: config.backoff,
|
|
4515
|
+
backpressure: config.backpressure
|
|
4516
|
+
});
|
|
4517
|
+
logger.info({ seeds: this.clusterConfig.seeds }, "TopGunClient initialized in cluster mode");
|
|
4518
|
+
} else {
|
|
4519
|
+
this.syncEngine = new SyncEngine({
|
|
4520
|
+
nodeId: this.nodeId,
|
|
4521
|
+
serverUrl: config.serverUrl,
|
|
4522
|
+
storageAdapter: this.storageAdapter,
|
|
4523
|
+
backoff: config.backoff,
|
|
4524
|
+
backpressure: config.backpressure
|
|
4525
|
+
});
|
|
4526
|
+
logger.info({ serverUrl: config.serverUrl }, "TopGunClient initialized in single-server mode");
|
|
4527
|
+
}
|
|
1598
4528
|
}
|
|
1599
4529
|
async start() {
|
|
1600
4530
|
await this.storageAdapter.initialize("topgun_offline_db");
|
|
@@ -1628,6 +4558,34 @@ var TopGunClient = class {
|
|
|
1628
4558
|
}
|
|
1629
4559
|
return this.topicHandles.get(name);
|
|
1630
4560
|
}
|
|
4561
|
+
/**
|
|
4562
|
+
* Retrieves a PN Counter instance. If the counter doesn't exist locally, it's created.
|
|
4563
|
+
* PN Counters support increment and decrement operations that work offline
|
|
4564
|
+
* and sync to server when connected.
|
|
4565
|
+
*
|
|
4566
|
+
* @param name The name of the counter (e.g., 'likes:post-123')
|
|
4567
|
+
* @returns A PNCounterHandle instance
|
|
4568
|
+
*
|
|
4569
|
+
* @example
|
|
4570
|
+
* ```typescript
|
|
4571
|
+
* const likes = client.getPNCounter('likes:post-123');
|
|
4572
|
+
* likes.increment(); // +1
|
|
4573
|
+
* likes.decrement(); // -1
|
|
4574
|
+
* likes.addAndGet(10); // +10
|
|
4575
|
+
*
|
|
4576
|
+
* likes.subscribe((value) => {
|
|
4577
|
+
* console.log('Current likes:', value);
|
|
4578
|
+
* });
|
|
4579
|
+
* ```
|
|
4580
|
+
*/
|
|
4581
|
+
getPNCounter(name) {
|
|
4582
|
+
let counter = this.counters.get(name);
|
|
4583
|
+
if (!counter) {
|
|
4584
|
+
counter = new PNCounterHandle(name, this.nodeId, this.syncEngine, this.storageAdapter);
|
|
4585
|
+
this.counters.set(name, counter);
|
|
4586
|
+
}
|
|
4587
|
+
return counter;
|
|
4588
|
+
}
|
|
1631
4589
|
/**
|
|
1632
4590
|
* Retrieves an LWWMap instance. If the map doesn't exist locally, it's created.
|
|
1633
4591
|
* @param name The name of the map.
|
|
@@ -1754,9 +4712,69 @@ var TopGunClient = class {
|
|
|
1754
4712
|
* Closes the client, disconnecting from the server and cleaning up resources.
|
|
1755
4713
|
*/
|
|
1756
4714
|
close() {
|
|
4715
|
+
if (this.clusterClient) {
|
|
4716
|
+
this.clusterClient.close();
|
|
4717
|
+
}
|
|
1757
4718
|
this.syncEngine.close();
|
|
1758
4719
|
}
|
|
1759
4720
|
// ============================================
|
|
4721
|
+
// Cluster Mode API
|
|
4722
|
+
// ============================================
|
|
4723
|
+
/**
|
|
4724
|
+
* Check if running in cluster mode
|
|
4725
|
+
*/
|
|
4726
|
+
isCluster() {
|
|
4727
|
+
return this.isClusterMode;
|
|
4728
|
+
}
|
|
4729
|
+
/**
|
|
4730
|
+
* Get list of connected cluster nodes (cluster mode only)
|
|
4731
|
+
* @returns Array of connected node IDs, or empty array in single-server mode
|
|
4732
|
+
*/
|
|
4733
|
+
getConnectedNodes() {
|
|
4734
|
+
if (!this.clusterClient) return [];
|
|
4735
|
+
return this.clusterClient.getConnectedNodes();
|
|
4736
|
+
}
|
|
4737
|
+
/**
|
|
4738
|
+
* Get the current partition map version (cluster mode only)
|
|
4739
|
+
* @returns Partition map version, or 0 in single-server mode
|
|
4740
|
+
*/
|
|
4741
|
+
getPartitionMapVersion() {
|
|
4742
|
+
if (!this.clusterClient) return 0;
|
|
4743
|
+
return this.clusterClient.getRouterStats().mapVersion;
|
|
4744
|
+
}
|
|
4745
|
+
/**
|
|
4746
|
+
* Check if direct routing is active (cluster mode only)
|
|
4747
|
+
* Direct routing sends operations directly to partition owners.
|
|
4748
|
+
* @returns true if routing is active, false otherwise
|
|
4749
|
+
*/
|
|
4750
|
+
isRoutingActive() {
|
|
4751
|
+
if (!this.clusterClient) return false;
|
|
4752
|
+
return this.clusterClient.isRoutingActive();
|
|
4753
|
+
}
|
|
4754
|
+
/**
|
|
4755
|
+
* Get health status for all cluster nodes (cluster mode only)
|
|
4756
|
+
* @returns Map of node IDs to their health status
|
|
4757
|
+
*/
|
|
4758
|
+
getClusterHealth() {
|
|
4759
|
+
if (!this.clusterClient) return /* @__PURE__ */ new Map();
|
|
4760
|
+
return this.clusterClient.getHealthStatus();
|
|
4761
|
+
}
|
|
4762
|
+
/**
|
|
4763
|
+
* Force refresh of partition map (cluster mode only)
|
|
4764
|
+
* Use this after detecting routing errors.
|
|
4765
|
+
*/
|
|
4766
|
+
async refreshPartitionMap() {
|
|
4767
|
+
if (!this.clusterClient) return;
|
|
4768
|
+
await this.clusterClient.refreshPartitionMap();
|
|
4769
|
+
}
|
|
4770
|
+
/**
|
|
4771
|
+
* Get cluster router statistics (cluster mode only)
|
|
4772
|
+
*/
|
|
4773
|
+
getClusterStats() {
|
|
4774
|
+
if (!this.clusterClient) return null;
|
|
4775
|
+
return this.clusterClient.getRouterStats();
|
|
4776
|
+
}
|
|
4777
|
+
// ============================================
|
|
1760
4778
|
// Connection State API
|
|
1761
4779
|
// ============================================
|
|
1762
4780
|
/**
|
|
@@ -1840,6 +4858,175 @@ var TopGunClient = class {
|
|
|
1840
4858
|
onBackpressure(event, listener) {
|
|
1841
4859
|
return this.syncEngine.onBackpressure(event, listener);
|
|
1842
4860
|
}
|
|
4861
|
+
// ============================================
|
|
4862
|
+
// Entry Processor API (Phase 5.03)
|
|
4863
|
+
// ============================================
|
|
4864
|
+
/**
|
|
4865
|
+
* Execute an entry processor on a single key atomically.
|
|
4866
|
+
*
|
|
4867
|
+
* Entry processors solve the read-modify-write race condition by executing
|
|
4868
|
+
* user-defined logic atomically on the server where the data lives.
|
|
4869
|
+
*
|
|
4870
|
+
* @param mapName Name of the map
|
|
4871
|
+
* @param key Key to process
|
|
4872
|
+
* @param processor Processor definition with name, code, and optional args
|
|
4873
|
+
* @returns Promise resolving to the processor result
|
|
4874
|
+
*
|
|
4875
|
+
* @example
|
|
4876
|
+
* ```typescript
|
|
4877
|
+
* // Increment a counter atomically
|
|
4878
|
+
* const result = await client.executeOnKey('stats', 'pageViews', {
|
|
4879
|
+
* name: 'increment',
|
|
4880
|
+
* code: `
|
|
4881
|
+
* const current = value ?? 0;
|
|
4882
|
+
* return { value: current + 1, result: current + 1 };
|
|
4883
|
+
* `,
|
|
4884
|
+
* });
|
|
4885
|
+
*
|
|
4886
|
+
* // Using built-in processor
|
|
4887
|
+
* import { BuiltInProcessors } from '@topgunbuild/core';
|
|
4888
|
+
* const result = await client.executeOnKey(
|
|
4889
|
+
* 'stats',
|
|
4890
|
+
* 'pageViews',
|
|
4891
|
+
* BuiltInProcessors.INCREMENT(1)
|
|
4892
|
+
* );
|
|
4893
|
+
* ```
|
|
4894
|
+
*/
|
|
4895
|
+
async executeOnKey(mapName, key, processor) {
|
|
4896
|
+
const result = await this.syncEngine.executeOnKey(mapName, key, processor);
|
|
4897
|
+
if (result.success && result.newValue !== void 0) {
|
|
4898
|
+
const map = this.maps.get(mapName);
|
|
4899
|
+
if (map instanceof LWWMap2) {
|
|
4900
|
+
map.set(key, result.newValue);
|
|
4901
|
+
}
|
|
4902
|
+
}
|
|
4903
|
+
return result;
|
|
4904
|
+
}
|
|
4905
|
+
/**
|
|
4906
|
+
* Execute an entry processor on multiple keys.
|
|
4907
|
+
*
|
|
4908
|
+
* Each key is processed atomically. The operation returns when all keys
|
|
4909
|
+
* have been processed.
|
|
4910
|
+
*
|
|
4911
|
+
* @param mapName Name of the map
|
|
4912
|
+
* @param keys Keys to process
|
|
4913
|
+
* @param processor Processor definition
|
|
4914
|
+
* @returns Promise resolving to a map of key -> result
|
|
4915
|
+
*
|
|
4916
|
+
* @example
|
|
4917
|
+
* ```typescript
|
|
4918
|
+
* // Reset multiple counters
|
|
4919
|
+
* const results = await client.executeOnKeys(
|
|
4920
|
+
* 'stats',
|
|
4921
|
+
* ['pageViews', 'uniqueVisitors', 'bounceRate'],
|
|
4922
|
+
* {
|
|
4923
|
+
* name: 'reset',
|
|
4924
|
+
* code: `return { value: 0, result: value };`, // Returns old value
|
|
4925
|
+
* }
|
|
4926
|
+
* );
|
|
4927
|
+
*
|
|
4928
|
+
* for (const [key, result] of results) {
|
|
4929
|
+
* console.log(`${key}: was ${result.result}, now 0`);
|
|
4930
|
+
* }
|
|
4931
|
+
* ```
|
|
4932
|
+
*/
|
|
4933
|
+
async executeOnKeys(mapName, keys, processor) {
|
|
4934
|
+
const results = await this.syncEngine.executeOnKeys(mapName, keys, processor);
|
|
4935
|
+
const map = this.maps.get(mapName);
|
|
4936
|
+
if (map instanceof LWWMap2) {
|
|
4937
|
+
for (const [key, result] of results) {
|
|
4938
|
+
if (result.success && result.newValue !== void 0) {
|
|
4939
|
+
map.set(key, result.newValue);
|
|
4940
|
+
}
|
|
4941
|
+
}
|
|
4942
|
+
}
|
|
4943
|
+
return results;
|
|
4944
|
+
}
|
|
4945
|
+
/**
|
|
4946
|
+
* Get the Event Journal reader for subscribing to and reading
|
|
4947
|
+
* map change events.
|
|
4948
|
+
*
|
|
4949
|
+
* The Event Journal provides:
|
|
4950
|
+
* - Append-only log of all map changes (PUT, UPDATE, DELETE)
|
|
4951
|
+
* - Subscription to real-time events
|
|
4952
|
+
* - Historical event replay
|
|
4953
|
+
* - Audit trail for compliance
|
|
4954
|
+
*
|
|
4955
|
+
* @returns EventJournalReader instance
|
|
4956
|
+
*
|
|
4957
|
+
* @example
|
|
4958
|
+
* ```typescript
|
|
4959
|
+
* const journal = client.getEventJournal();
|
|
4960
|
+
*
|
|
4961
|
+
* // Subscribe to all events
|
|
4962
|
+
* const unsubscribe = journal.subscribe((event) => {
|
|
4963
|
+
* console.log(`${event.type} on ${event.mapName}:${event.key}`);
|
|
4964
|
+
* });
|
|
4965
|
+
*
|
|
4966
|
+
* // Subscribe to specific map
|
|
4967
|
+
* journal.subscribe(
|
|
4968
|
+
* (event) => console.log('User changed:', event.key),
|
|
4969
|
+
* { mapName: 'users' }
|
|
4970
|
+
* );
|
|
4971
|
+
*
|
|
4972
|
+
* // Read historical events
|
|
4973
|
+
* const events = await journal.readFrom(0n, 100);
|
|
4974
|
+
* ```
|
|
4975
|
+
*/
|
|
4976
|
+
getEventJournal() {
|
|
4977
|
+
if (!this.journalReader) {
|
|
4978
|
+
this.journalReader = new EventJournalReader(this.syncEngine);
|
|
4979
|
+
}
|
|
4980
|
+
return this.journalReader;
|
|
4981
|
+
}
|
|
4982
|
+
// ============================================
|
|
4983
|
+
// Conflict Resolver API (Phase 5.05)
|
|
4984
|
+
// ============================================
|
|
4985
|
+
/**
|
|
4986
|
+
* Get the conflict resolver client for registering custom merge resolvers.
|
|
4987
|
+
*
|
|
4988
|
+
* Conflict resolvers allow you to customize how merge conflicts are handled
|
|
4989
|
+
* on the server. You can implement business logic like:
|
|
4990
|
+
* - First-write-wins for booking systems
|
|
4991
|
+
* - Numeric constraints (non-negative, min/max)
|
|
4992
|
+
* - Owner-only modifications
|
|
4993
|
+
* - Custom merge strategies
|
|
4994
|
+
*
|
|
4995
|
+
* @returns ConflictResolverClient instance
|
|
4996
|
+
*
|
|
4997
|
+
* @example
|
|
4998
|
+
* ```typescript
|
|
4999
|
+
* const resolvers = client.getConflictResolvers();
|
|
5000
|
+
*
|
|
5001
|
+
* // Register a first-write-wins resolver
|
|
5002
|
+
* await resolvers.register('bookings', {
|
|
5003
|
+
* name: 'first-write-wins',
|
|
5004
|
+
* code: `
|
|
5005
|
+
* if (context.localValue !== undefined) {
|
|
5006
|
+
* return { action: 'reject', reason: 'Slot already booked' };
|
|
5007
|
+
* }
|
|
5008
|
+
* return { action: 'accept', value: context.remoteValue };
|
|
5009
|
+
* `,
|
|
5010
|
+
* priority: 100,
|
|
5011
|
+
* });
|
|
5012
|
+
*
|
|
5013
|
+
* // Subscribe to merge rejections
|
|
5014
|
+
* resolvers.onRejection((rejection) => {
|
|
5015
|
+
* console.log(`Merge rejected: ${rejection.reason}`);
|
|
5016
|
+
* // Optionally refresh local state
|
|
5017
|
+
* });
|
|
5018
|
+
*
|
|
5019
|
+
* // List registered resolvers
|
|
5020
|
+
* const registered = await resolvers.list('bookings');
|
|
5021
|
+
* console.log('Active resolvers:', registered);
|
|
5022
|
+
*
|
|
5023
|
+
* // Unregister when done
|
|
5024
|
+
* await resolvers.unregister('bookings', 'first-write-wins');
|
|
5025
|
+
* ```
|
|
5026
|
+
*/
|
|
5027
|
+
getConflictResolvers() {
|
|
5028
|
+
return this.syncEngine.getConflictResolverClient();
|
|
5029
|
+
}
|
|
1843
5030
|
};
|
|
1844
5031
|
|
|
1845
5032
|
// src/adapters/IDBAdapter.ts
|
|
@@ -2107,14 +5294,14 @@ var CollectionWrapper = class {
|
|
|
2107
5294
|
};
|
|
2108
5295
|
|
|
2109
5296
|
// src/crypto/EncryptionManager.ts
|
|
2110
|
-
import { serialize as
|
|
5297
|
+
import { serialize as serialize4, deserialize as deserialize3 } from "@topgunbuild/core";
|
|
2111
5298
|
var _EncryptionManager = class _EncryptionManager {
|
|
2112
5299
|
/**
|
|
2113
5300
|
* Encrypts data using AES-GCM.
|
|
2114
5301
|
* Serializes data to MessagePack before encryption.
|
|
2115
5302
|
*/
|
|
2116
5303
|
static async encrypt(key, data) {
|
|
2117
|
-
const encoded =
|
|
5304
|
+
const encoded = serialize4(data);
|
|
2118
5305
|
const iv = window.crypto.getRandomValues(new Uint8Array(_EncryptionManager.IV_LENGTH));
|
|
2119
5306
|
const ciphertext = await window.crypto.subtle.encrypt(
|
|
2120
5307
|
{
|
|
@@ -2143,7 +5330,7 @@ var _EncryptionManager = class _EncryptionManager {
|
|
|
2143
5330
|
key,
|
|
2144
5331
|
record.data
|
|
2145
5332
|
);
|
|
2146
|
-
return
|
|
5333
|
+
return deserialize3(new Uint8Array(plaintextBuffer));
|
|
2147
5334
|
} catch (err) {
|
|
2148
5335
|
console.error("Decryption failed", err);
|
|
2149
5336
|
throw new Error("Failed to decrypt data: " + err);
|
|
@@ -2270,12 +5457,21 @@ var EncryptedStorageAdapter = class {
|
|
|
2270
5457
|
import { LWWMap as LWWMap3, Predicates } from "@topgunbuild/core";
|
|
2271
5458
|
export {
|
|
2272
5459
|
BackpressureError,
|
|
5460
|
+
ChangeTracker,
|
|
5461
|
+
ClusterClient,
|
|
5462
|
+
ConflictResolverClient,
|
|
5463
|
+
ConnectionPool,
|
|
2273
5464
|
DEFAULT_BACKPRESSURE_CONFIG,
|
|
5465
|
+
DEFAULT_CLUSTER_CONFIG,
|
|
2274
5466
|
EncryptedStorageAdapter,
|
|
5467
|
+
EventJournalReader,
|
|
2275
5468
|
IDBAdapter,
|
|
2276
5469
|
LWWMap3 as LWWMap,
|
|
5470
|
+
PNCounterHandle,
|
|
5471
|
+
PartitionRouter,
|
|
2277
5472
|
Predicates,
|
|
2278
5473
|
QueryHandle,
|
|
5474
|
+
SingleServerProvider,
|
|
2279
5475
|
SyncEngine,
|
|
2280
5476
|
SyncState,
|
|
2281
5477
|
SyncStateMachine,
|