@syncular/server-hono 0.0.6-125 → 0.0.6-135

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/src/routes.ts CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  type CompactOptions,
34
34
  createServerHandlerCollection,
35
35
  InvalidSubscriptionScopeError,
36
+ maybeCompactChanges,
37
+ maybePruneSync,
36
38
  type PruneOptions,
37
39
  type PullResult,
38
40
  pull,
@@ -127,7 +129,7 @@ export interface SyncRoutesConfigWithRateLimit {
127
129
  requestPayloadSnapshots?: {
128
130
  /**
129
131
  * Enable payload snapshot storage in `sync_request_payloads`.
130
- * Default: true when console event recording is enabled.
132
+ * Default: false (opt-in).
131
133
  */
132
134
  enabled?: boolean;
133
135
  /**
@@ -496,14 +498,28 @@ export function createSyncRoutes<
496
498
  const maxPullLimitSnapshotRows = config.maxPullLimitSnapshotRows ?? 5000;
497
499
  const maxPullMaxSnapshotPages = config.maxPullMaxSnapshotPages ?? 10;
498
500
  const maxOperationsPerPush = config.maxOperationsPerPush ?? 200;
501
+ const requestPayloadSnapshots = config.requestPayloadSnapshots;
502
+ const requestPayloadSnapshotsEnabled =
503
+ requestPayloadSnapshots?.enabled ??
504
+ requestPayloadSnapshots?.maxBytes !== undefined;
505
+ const pruneConfig = config.prune;
506
+ const compactConfig = config.compact;
507
+ const pruneMinIntervalMs = readPositiveInteger(
508
+ pruneConfig?.minIntervalMs,
509
+ 5 * 60 * 1000
510
+ );
511
+ const compactMinIntervalMs = readPositiveInteger(
512
+ compactConfig?.minIntervalMs,
513
+ 30 * 60 * 1000
514
+ );
515
+ const compactOptions = compactConfig?.options;
499
516
  const consoleLiveEmitter = options.consoleLiveEmitter;
500
517
  const shouldEmitConsoleLiveEvents = consoleLiveEmitter !== undefined;
501
518
  const shouldRecordRequestEvents = shouldEmitConsoleLiveEvents;
502
519
  const shouldCaptureRequestPayloadSnapshots =
503
- shouldRecordRequestEvents &&
504
- config.requestPayloadSnapshots?.enabled !== false;
520
+ shouldRecordRequestEvents && requestPayloadSnapshotsEnabled;
505
521
  const requestPayloadSnapshotMaxBytes = readPositiveInteger(
506
- config.requestPayloadSnapshots?.maxBytes,
522
+ requestPayloadSnapshots?.maxBytes,
507
523
  DEFAULT_REQUEST_PAYLOAD_SNAPSHOT_MAX_BYTES
508
524
  );
509
525
  const consoleSchemaReadyBase = shouldRecordRequestEvents
@@ -565,6 +581,76 @@ export function createSyncRoutes<
565
581
  logSyncEvent(event);
566
582
  };
567
583
 
584
+ if (compactConfig && !compactOptions) {
585
+ logSyncEvent({
586
+ event: 'sync.compact_auto_disabled',
587
+ reason: 'missing_options',
588
+ });
589
+ }
590
+
591
+ const triggerAutoMaintenance = (ctx: {
592
+ actorId: string;
593
+ clientId: string;
594
+ partitionId: string;
595
+ }): void => {
596
+ if (!pruneConfig && !compactConfig) return;
597
+
598
+ void (async () => {
599
+ if (pruneConfig) {
600
+ try {
601
+ const deleted = await maybePruneSync(options.db, {
602
+ minIntervalMs: pruneMinIntervalMs,
603
+ options: pruneConfig.options,
604
+ });
605
+ if (deleted > 0) {
606
+ logSyncEvent({
607
+ event: 'sync.prune_auto',
608
+ userId: ctx.actorId,
609
+ clientId: ctx.clientId,
610
+ partitionId: ctx.partitionId,
611
+ deletedCount: deleted,
612
+ });
613
+ }
614
+ } catch (error) {
615
+ logAsyncFailureOnce('sync.prune_auto_failed', {
616
+ event: 'sync.prune_auto_failed',
617
+ userId: ctx.actorId,
618
+ clientId: ctx.clientId,
619
+ partitionId: ctx.partitionId,
620
+ error: error instanceof Error ? error.message : String(error),
621
+ });
622
+ }
623
+ }
624
+
625
+ if (compactConfig && compactOptions) {
626
+ try {
627
+ const deleted = await maybeCompactChanges(options.db, {
628
+ dialect: options.dialect,
629
+ minIntervalMs: compactMinIntervalMs,
630
+ options: compactOptions,
631
+ });
632
+ if (deleted > 0) {
633
+ logSyncEvent({
634
+ event: 'sync.compact_auto',
635
+ userId: ctx.actorId,
636
+ clientId: ctx.clientId,
637
+ partitionId: ctx.partitionId,
638
+ deletedCount: deleted,
639
+ });
640
+ }
641
+ } catch (error) {
642
+ logAsyncFailureOnce('sync.compact_auto_failed', {
643
+ event: 'sync.compact_auto_failed',
644
+ userId: ctx.actorId,
645
+ clientId: ctx.clientId,
646
+ partitionId: ctx.partitionId,
647
+ error: error instanceof Error ? error.message : String(error),
648
+ });
649
+ }
650
+ }
651
+ })();
652
+ };
653
+
568
654
  if (wsConnectionManager && realtimeBroadcaster) {
569
655
  const unsubscribe = realtimeBroadcaster.subscribe(
570
656
  (event: SyncRealtimeEvent) => {
@@ -1182,6 +1268,12 @@ export function createSyncRoutes<
1182
1268
  pullResponse = pullResult.response;
1183
1269
  }
1184
1270
 
1271
+ triggerAutoMaintenance({
1272
+ actorId: auth.actorId,
1273
+ clientId,
1274
+ partitionId,
1275
+ });
1276
+
1185
1277
  return c.json(
1186
1278
  {
1187
1279
  ok: true as const,
@@ -1864,6 +1956,12 @@ export function createSyncRoutes<
1864
1956
  }));
1865
1957
  }
1866
1958
 
1959
+ triggerAutoMaintenance({
1960
+ actorId,
1961
+ clientId,
1962
+ partitionId,
1963
+ });
1964
+
1867
1965
  conn.sendPushResponse({
1868
1966
  requestId,
1869
1967
  ok: pushed.response.ok,
package/src/ws.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * Also supports presence tracking for collaborative features.
6
6
  */
7
7
 
8
+ import { RealtimeConnectionRegistry } from '@syncular/core';
8
9
  import type { WSContext } from 'hono/ws';
9
10
 
10
11
  /**
@@ -196,9 +197,7 @@ export function createWebSocketConnection(
196
197
  * Scope-key based notifications and presence tracking.
197
198
  */
198
199
  export class WebSocketConnectionManager {
199
- private connectionsByClientId = new Map<string, Set<WebSocketConnection>>();
200
- private scopeKeysByClientId = new Map<string, Set<string>>();
201
- private connectionsByScopeKey = new Map<string, Set<WebSocketConnection>>();
200
+ private readonly registry: RealtimeConnectionRegistry<WebSocketConnection>;
202
201
 
203
202
  /**
204
203
  * In-memory presence tracking by scope key.
@@ -217,15 +216,17 @@ export class WebSocketConnectionManager {
217
216
  metadata?: Record<string, unknown>;
218
217
  }) => void;
219
218
 
220
- private heartbeatIntervalMs: number;
221
- private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
222
-
223
219
  constructor(options?: {
224
220
  heartbeatIntervalMs?: number;
225
221
  onPresenceChange?: WebSocketConnectionManager['onPresenceChange'];
226
222
  }) {
227
- this.heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 30_000;
228
223
  this.onPresenceChange = options?.onPresenceChange;
224
+ this.registry = new RealtimeConnectionRegistry({
225
+ heartbeatIntervalMs: options?.heartbeatIntervalMs,
226
+ onClientDisconnected: (clientId) => {
227
+ this.cleanupClientPresence(clientId);
228
+ },
229
+ });
229
230
  }
230
231
 
231
232
  /**
@@ -236,35 +237,7 @@ export class WebSocketConnectionManager {
236
237
  connection: WebSocketConnection,
237
238
  initialScopeKeys: string[] = []
238
239
  ): () => void {
239
- const clientId = connection.clientId;
240
- let clientConns = this.connectionsByClientId.get(clientId);
241
- if (!clientConns) {
242
- clientConns = new Set();
243
- this.connectionsByClientId.set(clientId, clientConns);
244
- }
245
- clientConns.add(connection);
246
-
247
- if (!this.scopeKeysByClientId.has(clientId)) {
248
- this.scopeKeysByClientId.set(clientId, new Set(initialScopeKeys));
249
- }
250
-
251
- const scopeKeys =
252
- this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
253
- for (const k of scopeKeys) {
254
- let scopeConns = this.connectionsByScopeKey.get(k);
255
- if (!scopeConns) {
256
- scopeConns = new Set();
257
- this.connectionsByScopeKey.set(k, scopeConns);
258
- }
259
- scopeConns.add(connection);
260
- }
261
-
262
- this.ensureHeartbeat();
263
-
264
- return () => {
265
- this.unregister(connection);
266
- this.ensureHeartbeat();
267
- };
240
+ return this.registry.register(connection, initialScopeKeys);
268
241
  }
269
242
 
270
243
  /**
@@ -272,52 +245,14 @@ export class WebSocketConnectionManager {
272
245
  * If the client has no active connections, this is a no-op.
273
246
  */
274
247
  updateClientScopeKeys(clientId: string, scopeKeys: string[]): void {
275
- const conns = this.connectionsByClientId.get(clientId);
276
- if (!conns || conns.size === 0) return;
277
-
278
- const next = new Set(scopeKeys);
279
- const prev = this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
280
-
281
- // No-op when unchanged (reduces write load when clients pull frequently).
282
- if (prev.size === next.size) {
283
- let unchanged = true;
284
- for (const k of prev) {
285
- if (!next.has(k)) {
286
- unchanged = false;
287
- break;
288
- }
289
- }
290
- if (unchanged) return;
291
- }
292
-
293
- this.scopeKeysByClientId.set(clientId, next);
294
-
295
- for (const k of prev) {
296
- if (next.has(k)) continue;
297
- const set = this.connectionsByScopeKey.get(k);
298
- if (!set) continue;
299
- for (const conn of conns) set.delete(conn);
300
- if (set.size === 0) this.connectionsByScopeKey.delete(k);
301
- }
302
-
303
- for (const k of next) {
304
- if (prev.has(k)) continue;
305
- let set = this.connectionsByScopeKey.get(k);
306
- if (!set) {
307
- set = new Set();
308
- this.connectionsByScopeKey.set(k, set);
309
- }
310
- for (const conn of conns) set.add(conn);
311
- }
248
+ this.registry.updateClientScopeKeys(clientId, scopeKeys);
312
249
  }
313
250
 
314
251
  /**
315
252
  * Check whether a client is currently authorized/subscribed for a scope key.
316
253
  */
317
254
  isClientSubscribedToScopeKey(clientId: string, scopeKey: string): boolean {
318
- const scopeKeys = this.scopeKeysByClientId.get(clientId);
319
- if (!scopeKeys || scopeKeys.size === 0) return false;
320
- return scopeKeys.has(scopeKey);
255
+ return this.registry.isClientSubscribedToScopeKey(clientId, scopeKey);
321
256
  }
322
257
 
323
258
  // =========================================================================
@@ -333,7 +268,7 @@ export class WebSocketConnectionManager {
333
268
  scopeKey: string,
334
269
  metadata?: Record<string, unknown>
335
270
  ): boolean {
336
- const conns = this.connectionsByClientId.get(clientId);
271
+ const conns = this.registry.getConnectionsForClient(clientId);
337
272
  if (!conns || conns.size === 0) return false;
338
273
  if (!this.isClientSubscribedToScopeKey(clientId, scopeKey)) return false;
339
274
 
@@ -555,15 +490,15 @@ export class WebSocketConnectionManager {
555
490
  metadata?: Record<string, unknown>;
556
491
  }
557
492
  ): void {
558
- const conns = this.connectionsByScopeKey.get(scopeKey);
559
- if (!conns) return;
560
-
561
- for (const conn of conns) {
562
- if (!conn.isOpen) continue;
563
- // Don't send presence events back to the source client
564
- if (event.clientId && conn.clientId === event.clientId) continue;
565
- conn.sendPresence(event);
566
- }
493
+ this.registry.forEachConnectionInScopeKeys(
494
+ [scopeKey],
495
+ (conn) => {
496
+ conn.sendPresence(event);
497
+ },
498
+ {
499
+ excludeClientIds: event.clientId ? [event.clientId] : undefined,
500
+ }
501
+ );
567
502
  }
568
503
 
569
504
  /**
@@ -602,30 +537,21 @@ export class WebSocketConnectionManager {
602
537
  // Sync Notifications
603
538
  // =========================================================================
604
539
 
605
- /**
606
- * Notify clients that new data is available for the given scopes.
607
- * Dedupes connections that match multiple scopes.
608
- */
609
540
  /**
610
541
  * Maximum serialized size (bytes) for inline WS change delivery.
611
542
  * Larger payloads fall back to cursor-only notification.
612
543
  */
613
544
  private static readonly WS_INLINE_MAX_BYTES = 64 * 1024;
614
545
 
546
+ /**
547
+ * Notify clients that new data is available for the given scopes.
548
+ * Dedupes connections that match multiple scopes.
549
+ */
615
550
  notifyScopeKeys(
616
551
  scopeKeys: string[],
617
552
  cursor: number,
618
553
  opts?: { excludeClientIds?: string[]; changes?: unknown[] }
619
554
  ): void {
620
- const exclude = new Set(opts?.excludeClientIds ?? []);
621
- const targets = new Set<WebSocketConnection>();
622
-
623
- for (const k of scopeKeys) {
624
- const conns = this.connectionsByScopeKey.get(k);
625
- if (!conns) continue;
626
- for (const conn of conns) targets.add(conn);
627
- }
628
-
629
555
  // Size guard: only deliver inline changes if under threshold
630
556
  let inlineChanges: unknown[] | undefined;
631
557
  if (opts?.changes && opts.changes.length > 0) {
@@ -635,15 +561,17 @@ export class WebSocketConnectionManager {
635
561
  }
636
562
  }
637
563
 
638
- for (const conn of targets) {
639
- if (!conn.isOpen) continue;
640
- if (exclude.has(conn.clientId)) continue;
641
- if (inlineChanges) {
642
- conn.sendSync(cursor, inlineChanges);
643
- } else {
644
- conn.sendSync(cursor);
645
- }
646
- }
564
+ this.registry.forEachConnectionInScopeKeys(
565
+ scopeKeys,
566
+ (conn) => {
567
+ if (inlineChanges) {
568
+ conn.sendSync(cursor, inlineChanges);
569
+ } else {
570
+ conn.sendSync(cursor);
571
+ }
572
+ },
573
+ { excludeClientIds: opts?.excludeClientIds }
574
+ );
647
575
  }
648
576
 
649
577
  /**
@@ -651,26 +579,23 @@ export class WebSocketConnectionManager {
651
579
  * Used for external data changes that affect all clients regardless of scope.
652
580
  */
653
581
  notifyAllClients(cursor: number): void {
654
- for (const conns of this.connectionsByClientId.values()) {
655
- for (const conn of conns) {
656
- if (!conn.isOpen) continue;
657
- conn.sendSync(cursor);
658
- }
659
- }
582
+ this.registry.forEachConnection((conn) => {
583
+ conn.sendSync(cursor);
584
+ });
660
585
  }
661
586
 
662
587
  /**
663
588
  * Get the number of active connections for a client.
664
589
  */
665
590
  getConnectionCount(clientId: string): number {
666
- return this.connectionsByClientId.get(clientId)?.size ?? 0;
591
+ return this.registry.getConnectionCount(clientId);
667
592
  }
668
593
 
669
594
  /**
670
595
  * Get the current transport path for a client if connected.
671
596
  */
672
597
  getClientTransportPath(clientId: string): 'direct' | 'relay' | null {
673
- const conns = this.connectionsByClientId.get(clientId);
598
+ const conns = this.registry.getConnectionsForClient(clientId);
674
599
  if (!conns || conns.size === 0) {
675
600
  return null;
676
601
  }
@@ -688,115 +613,21 @@ export class WebSocketConnectionManager {
688
613
  * Get total number of active connections.
689
614
  */
690
615
  getTotalConnections(): number {
691
- let total = 0;
692
- for (const conns of this.connectionsByClientId.values()) {
693
- total += conns.size;
694
- }
695
- return total;
616
+ return this.registry.getTotalConnections();
696
617
  }
697
618
 
698
619
  /**
699
620
  * Close all connections for a client.
700
621
  */
701
622
  closeClientConnections(clientId: string): void {
702
- const conns = this.connectionsByClientId.get(clientId);
703
- if (!conns) return;
704
-
705
- const scopeKeys =
706
- this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
707
- for (const k of scopeKeys) {
708
- const set = this.connectionsByScopeKey.get(k);
709
- if (!set) continue;
710
- for (const conn of conns) set.delete(conn);
711
- if (set.size === 0) this.connectionsByScopeKey.delete(k);
712
- }
713
-
714
- for (const conn of conns) {
715
- conn.close(1000, 'client closed');
716
- }
717
- this.connectionsByClientId.delete(clientId);
718
- this.scopeKeysByClientId.delete(clientId);
719
- this.ensureHeartbeat();
623
+ this.registry.closeClientConnections(clientId, 1000, 'client closed');
720
624
  }
721
625
 
722
626
  /**
723
627
  * Close all connections.
724
628
  */
725
629
  closeAll(): void {
726
- for (const conns of this.connectionsByClientId.values()) {
727
- for (const conn of conns) {
728
- conn.close(1000, 'server shutdown');
729
- }
730
- }
731
- this.connectionsByClientId.clear();
732
- this.scopeKeysByClientId.clear();
733
- this.connectionsByScopeKey.clear();
630
+ this.registry.closeAll(1000, 'server shutdown');
734
631
  this.presenceByScopeKey.clear();
735
- this.ensureHeartbeat();
736
- }
737
-
738
- private ensureHeartbeat(): void {
739
- if (this.heartbeatIntervalMs <= 0) return;
740
-
741
- const total = this.getTotalConnections();
742
-
743
- if (total === 0) {
744
- if (this.heartbeatTimer) {
745
- clearInterval(this.heartbeatTimer);
746
- this.heartbeatTimer = null;
747
- }
748
- return;
749
- }
750
-
751
- if (this.heartbeatTimer) return;
752
-
753
- this.heartbeatTimer = setInterval(() => {
754
- this.sendHeartbeats();
755
- }, this.heartbeatIntervalMs);
756
- }
757
-
758
- private sendHeartbeats(): void {
759
- const closed: WebSocketConnection[] = [];
760
-
761
- for (const conns of this.connectionsByClientId.values()) {
762
- for (const conn of conns) {
763
- if (!conn.isOpen) {
764
- closed.push(conn);
765
- continue;
766
- }
767
- conn.sendHeartbeat();
768
- }
769
- }
770
-
771
- for (const conn of closed) {
772
- this.unregister(conn);
773
- }
774
-
775
- // Might have removed last connection.
776
- this.ensureHeartbeat();
777
- }
778
-
779
- private unregister(connection: WebSocketConnection): void {
780
- const clientId = connection.clientId;
781
-
782
- const scopeKeys =
783
- this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
784
- for (const k of scopeKeys) {
785
- const set = this.connectionsByScopeKey.get(k);
786
- if (!set) continue;
787
- set.delete(connection);
788
- if (set.size === 0) this.connectionsByScopeKey.delete(k);
789
- }
790
-
791
- const conns = this.connectionsByClientId.get(clientId);
792
- if (!conns) return;
793
- conns.delete(connection);
794
- if (conns.size > 0) return;
795
-
796
- // Client fully disconnected - clean up presence
797
- this.cleanupClientPresence(clientId);
798
-
799
- this.connectionsByClientId.delete(clientId);
800
- this.scopeKeysByClientId.delete(clientId);
801
632
  }
802
633
  }