@syncular/relay 0.0.1-60

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.
Files changed (68) hide show
  1. package/dist/client-role/forward-engine.d.ts +63 -0
  2. package/dist/client-role/forward-engine.d.ts.map +1 -0
  3. package/dist/client-role/forward-engine.js +263 -0
  4. package/dist/client-role/forward-engine.js.map +1 -0
  5. package/dist/client-role/index.d.ts +9 -0
  6. package/dist/client-role/index.d.ts.map +1 -0
  7. package/dist/client-role/index.js +9 -0
  8. package/dist/client-role/index.js.map +1 -0
  9. package/dist/client-role/pull-engine.d.ts +70 -0
  10. package/dist/client-role/pull-engine.d.ts.map +1 -0
  11. package/dist/client-role/pull-engine.js +233 -0
  12. package/dist/client-role/pull-engine.js.map +1 -0
  13. package/dist/client-role/sequence-mapper.d.ts +65 -0
  14. package/dist/client-role/sequence-mapper.d.ts.map +1 -0
  15. package/dist/client-role/sequence-mapper.js +161 -0
  16. package/dist/client-role/sequence-mapper.js.map +1 -0
  17. package/dist/index.d.ts +37 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +44 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/migrate.d.ts +18 -0
  22. package/dist/migrate.d.ts.map +1 -0
  23. package/dist/migrate.js +99 -0
  24. package/dist/migrate.js.map +1 -0
  25. package/dist/mode-manager.d.ts +60 -0
  26. package/dist/mode-manager.d.ts.map +1 -0
  27. package/dist/mode-manager.js +114 -0
  28. package/dist/mode-manager.js.map +1 -0
  29. package/dist/realtime.d.ts +102 -0
  30. package/dist/realtime.d.ts.map +1 -0
  31. package/dist/realtime.js +305 -0
  32. package/dist/realtime.js.map +1 -0
  33. package/dist/relay.d.ts +188 -0
  34. package/dist/relay.d.ts.map +1 -0
  35. package/dist/relay.js +315 -0
  36. package/dist/relay.js.map +1 -0
  37. package/dist/schema.d.ts +158 -0
  38. package/dist/schema.d.ts.map +1 -0
  39. package/dist/schema.js +7 -0
  40. package/dist/schema.js.map +1 -0
  41. package/dist/server-role/index.d.ts +54 -0
  42. package/dist/server-role/index.d.ts.map +1 -0
  43. package/dist/server-role/index.js +198 -0
  44. package/dist/server-role/index.js.map +1 -0
  45. package/dist/server-role/pull.d.ts +25 -0
  46. package/dist/server-role/pull.d.ts.map +1 -0
  47. package/dist/server-role/pull.js +24 -0
  48. package/dist/server-role/pull.js.map +1 -0
  49. package/dist/server-role/push.d.ts +27 -0
  50. package/dist/server-role/push.d.ts.map +1 -0
  51. package/dist/server-role/push.js +98 -0
  52. package/dist/server-role/push.js.map +1 -0
  53. package/package.json +61 -0
  54. package/src/__tests__/relay.test.ts +464 -0
  55. package/src/bun-types.d.ts +50 -0
  56. package/src/client-role/forward-engine.ts +352 -0
  57. package/src/client-role/index.ts +9 -0
  58. package/src/client-role/pull-engine.ts +301 -0
  59. package/src/client-role/sequence-mapper.ts +201 -0
  60. package/src/index.ts +50 -0
  61. package/src/migrate.ts +113 -0
  62. package/src/mode-manager.ts +142 -0
  63. package/src/realtime.ts +370 -0
  64. package/src/relay.ts +421 -0
  65. package/src/schema.ts +171 -0
  66. package/src/server-role/index.ts +342 -0
  67. package/src/server-role/pull.ts +37 -0
  68. package/src/server-role/push.ts +130 -0
@@ -0,0 +1,370 @@
1
+ /**
2
+ * @syncular/relay - Realtime WebSocket Manager
3
+ *
4
+ * Manages WebSocket connections for local clients to receive
5
+ * instant notifications when data changes.
6
+ *
7
+ * Adapted from @syncular/server-hono/ws.ts for relay use.
8
+ */
9
+
10
+ /**
11
+ * WebSocket event data for sync notifications.
12
+ */
13
+ export interface RelayWebSocketEvent {
14
+ event: 'sync' | 'heartbeat' | 'error';
15
+ data: {
16
+ cursor?: number;
17
+ error?: string;
18
+ timestamp: number;
19
+ };
20
+ }
21
+
22
+ /**
23
+ * WebSocket connection interface for the relay.
24
+ */
25
+ export interface RelayWebSocketConnection {
26
+ sendSync(cursor: number): void;
27
+ sendHeartbeat(): void;
28
+ sendError(message: string): void;
29
+ close(code?: number, reason?: string): void;
30
+ isOpen: boolean;
31
+ actorId: string;
32
+ clientId: string;
33
+ }
34
+
35
+ /**
36
+ * Realtime manager for relay WebSocket connections.
37
+ *
38
+ * Tracks active connections by client ID and scope key for
39
+ * efficient notification routing.
40
+ */
41
+ export class RelayRealtime {
42
+ private connectionsByClientId = new Map<
43
+ string,
44
+ Set<RelayWebSocketConnection>
45
+ >();
46
+ private scopeKeysByClientId = new Map<string, Set<string>>();
47
+ private connectionsByScopeKey = new Map<
48
+ string,
49
+ Set<RelayWebSocketConnection>
50
+ >();
51
+
52
+ private heartbeatIntervalMs: number;
53
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
54
+
55
+ constructor(options?: { heartbeatIntervalMs?: number }) {
56
+ this.heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 30_000;
57
+ }
58
+
59
+ /**
60
+ * Register a connection for a client.
61
+ * Returns a cleanup function to unregister.
62
+ */
63
+ register(
64
+ connection: RelayWebSocketConnection,
65
+ initialScopeKeys: string[] = []
66
+ ): () => void {
67
+ const clientId = connection.clientId;
68
+ let clientConns = this.connectionsByClientId.get(clientId);
69
+ if (!clientConns) {
70
+ clientConns = new Set();
71
+ this.connectionsByClientId.set(clientId, clientConns);
72
+ }
73
+ clientConns.add(connection);
74
+
75
+ if (!this.scopeKeysByClientId.has(clientId)) {
76
+ this.scopeKeysByClientId.set(clientId, new Set(initialScopeKeys));
77
+ }
78
+
79
+ const scopeKeys =
80
+ this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
81
+ for (const k of scopeKeys) {
82
+ let scopeConns = this.connectionsByScopeKey.get(k);
83
+ if (!scopeConns) {
84
+ scopeConns = new Set();
85
+ this.connectionsByScopeKey.set(k, scopeConns);
86
+ }
87
+ scopeConns.add(connection);
88
+ }
89
+
90
+ this.ensureHeartbeat();
91
+
92
+ return () => {
93
+ this.unregister(connection);
94
+ this.ensureHeartbeat();
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Update the effective tables/scopes for an already-connected client.
100
+ * In the new scope model, this is called with table names.
101
+ */
102
+ updateClientTables(clientId: string, tables: string[]): void {
103
+ this._updateScopeKeys(clientId, tables);
104
+ }
105
+
106
+ /**
107
+ * Alias for backwards compatibility.
108
+ */
109
+ updateClientScopeKeys(clientId: string, scopeKeys: string[]): void {
110
+ this._updateScopeKeys(clientId, scopeKeys);
111
+ }
112
+
113
+ private _updateScopeKeys(clientId: string, keys: string[]): void {
114
+ const conns = this.connectionsByClientId.get(clientId);
115
+ if (!conns || conns.size === 0) return;
116
+
117
+ const next = new Set<string>(keys);
118
+ const prev = this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
119
+
120
+ // No-op when unchanged
121
+ if (prev.size === next.size) {
122
+ let unchanged = true;
123
+ for (const k of prev) {
124
+ if (!next.has(k)) {
125
+ unchanged = false;
126
+ break;
127
+ }
128
+ }
129
+ if (unchanged) return;
130
+ }
131
+
132
+ this.scopeKeysByClientId.set(clientId, next);
133
+
134
+ // Remove from old scopes
135
+ for (const k of prev) {
136
+ if (next.has(k)) continue;
137
+ const set = this.connectionsByScopeKey.get(k);
138
+ if (!set) continue;
139
+ for (const conn of conns) set.delete(conn);
140
+ if (set.size === 0) this.connectionsByScopeKey.delete(k);
141
+ }
142
+
143
+ // Add to new scopes
144
+ for (const k of next) {
145
+ if (prev.has(k)) continue;
146
+ let set = this.connectionsByScopeKey.get(k);
147
+ if (!set) {
148
+ set = new Set();
149
+ this.connectionsByScopeKey.set(k, set);
150
+ }
151
+ for (const conn of conns) set.add(conn);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Notify clients that new data is available for the given scopes.
157
+ */
158
+ notifyScopeKeys(
159
+ scopeKeys: string[],
160
+ cursor: number,
161
+ opts?: { excludeClientIds?: string[] }
162
+ ): void {
163
+ const exclude = new Set(opts?.excludeClientIds ?? []);
164
+ const targets = new Set<RelayWebSocketConnection>();
165
+
166
+ for (const k of scopeKeys) {
167
+ const conns = this.connectionsByScopeKey.get(k);
168
+ if (!conns) continue;
169
+ for (const conn of conns) targets.add(conn);
170
+ }
171
+
172
+ for (const conn of targets) {
173
+ if (!conn.isOpen) continue;
174
+ if (exclude.has(conn.clientId)) continue;
175
+ conn.sendSync(cursor);
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Get the number of active connections for a client.
181
+ */
182
+ getConnectionCount(clientId: string): number {
183
+ return this.connectionsByClientId.get(clientId)?.size ?? 0;
184
+ }
185
+
186
+ /**
187
+ * Get total number of active connections.
188
+ */
189
+ getTotalConnections(): number {
190
+ let total = 0;
191
+ for (const conns of this.connectionsByClientId.values()) {
192
+ total += conns.size;
193
+ }
194
+ return total;
195
+ }
196
+
197
+ /**
198
+ * Close all connections for a client.
199
+ */
200
+ closeClientConnections(clientId: string): void {
201
+ const conns = this.connectionsByClientId.get(clientId);
202
+ if (!conns) return;
203
+
204
+ const scopeKeys =
205
+ this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
206
+ for (const k of scopeKeys) {
207
+ const set = this.connectionsByScopeKey.get(k);
208
+ if (!set) continue;
209
+ for (const conn of conns) set.delete(conn);
210
+ if (set.size === 0) this.connectionsByScopeKey.delete(k);
211
+ }
212
+
213
+ for (const conn of conns) {
214
+ conn.close(1000, 'client closed');
215
+ }
216
+ this.connectionsByClientId.delete(clientId);
217
+ this.scopeKeysByClientId.delete(clientId);
218
+ this.ensureHeartbeat();
219
+ }
220
+
221
+ /**
222
+ * Close all connections.
223
+ */
224
+ closeAll(): void {
225
+ for (const conns of this.connectionsByClientId.values()) {
226
+ for (const conn of conns) {
227
+ conn.close(1000, 'server shutdown');
228
+ }
229
+ }
230
+ this.connectionsByClientId.clear();
231
+ this.scopeKeysByClientId.clear();
232
+ this.connectionsByScopeKey.clear();
233
+ this.ensureHeartbeat();
234
+ }
235
+
236
+ private ensureHeartbeat(): void {
237
+ if (this.heartbeatIntervalMs <= 0) return;
238
+
239
+ const total = this.getTotalConnections();
240
+
241
+ if (total === 0) {
242
+ if (this.heartbeatTimer) {
243
+ clearInterval(this.heartbeatTimer);
244
+ this.heartbeatTimer = null;
245
+ }
246
+ return;
247
+ }
248
+
249
+ if (this.heartbeatTimer) return;
250
+
251
+ this.heartbeatTimer = setInterval(() => {
252
+ this.sendHeartbeats();
253
+ }, this.heartbeatIntervalMs);
254
+ }
255
+
256
+ private sendHeartbeats(): void {
257
+ const closed: RelayWebSocketConnection[] = [];
258
+
259
+ for (const conns of this.connectionsByClientId.values()) {
260
+ for (const conn of conns) {
261
+ if (!conn.isOpen) {
262
+ closed.push(conn);
263
+ continue;
264
+ }
265
+ conn.sendHeartbeat();
266
+ }
267
+ }
268
+
269
+ for (const conn of closed) {
270
+ this.unregister(conn);
271
+ }
272
+
273
+ this.ensureHeartbeat();
274
+ }
275
+
276
+ private unregister(connection: RelayWebSocketConnection): void {
277
+ const clientId = connection.clientId;
278
+
279
+ const scopeKeys =
280
+ this.scopeKeysByClientId.get(clientId) ?? new Set<string>();
281
+ for (const k of scopeKeys) {
282
+ const set = this.connectionsByScopeKey.get(k);
283
+ if (!set) continue;
284
+ set.delete(connection);
285
+ if (set.size === 0) this.connectionsByScopeKey.delete(k);
286
+ }
287
+
288
+ const conns = this.connectionsByClientId.get(clientId);
289
+ if (!conns) return;
290
+ conns.delete(connection);
291
+ if (conns.size > 0) return;
292
+
293
+ this.connectionsByClientId.delete(clientId);
294
+ this.scopeKeysByClientId.delete(clientId);
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Create a WebSocket connection wrapper.
300
+ *
301
+ * Use this with your WebSocket library to create connections
302
+ * compatible with RelayRealtime.
303
+ */
304
+ export function createRelayWebSocketConnection(
305
+ ws: {
306
+ send(message: string): void;
307
+ close(code?: number, reason?: string): void;
308
+ readyState: number;
309
+ },
310
+ args: { actorId: string; clientId: string }
311
+ ): RelayWebSocketConnection {
312
+ let closed = false;
313
+
314
+ function safeSend(message: string): boolean {
315
+ try {
316
+ ws.send(message);
317
+ return true;
318
+ } catch {
319
+ return false;
320
+ }
321
+ }
322
+
323
+ const connection: RelayWebSocketConnection = {
324
+ get isOpen() {
325
+ if (closed) return false;
326
+ return ws.readyState === 1;
327
+ },
328
+ actorId: args.actorId,
329
+ clientId: args.clientId,
330
+ sendSync(cursor: number) {
331
+ if (!connection.isOpen) return;
332
+ const ok = safeSend(
333
+ JSON.stringify({
334
+ event: 'sync',
335
+ data: { cursor, timestamp: Date.now() },
336
+ })
337
+ );
338
+ if (!ok) closed = true;
339
+ },
340
+ sendHeartbeat() {
341
+ if (!connection.isOpen) return;
342
+ const ok = safeSend(
343
+ JSON.stringify({ event: 'heartbeat', data: { timestamp: Date.now() } })
344
+ );
345
+ if (!ok) closed = true;
346
+ },
347
+ sendError(message: string) {
348
+ if (connection.isOpen) {
349
+ safeSend(
350
+ JSON.stringify({
351
+ event: 'error',
352
+ data: { error: message, timestamp: Date.now() },
353
+ })
354
+ );
355
+ }
356
+ connection.close(1011, 'server error');
357
+ },
358
+ close(code?: number, reason?: string) {
359
+ if (closed) return;
360
+ closed = true;
361
+ try {
362
+ ws.close(code, reason);
363
+ } catch {
364
+ // ignore
365
+ }
366
+ },
367
+ };
368
+
369
+ return connection;
370
+ }