@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,464 @@
1
+ /**
2
+ * @syncular/relay - Tests
3
+ */
4
+
5
+ import Database from 'bun:sqlite';
6
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
7
+ import { createSqliteServerDialect } from '@syncular/server-dialect-sqlite';
8
+ import type { Dialect, QueryResult } from 'kysely';
9
+ import {
10
+ Kysely,
11
+ SqliteAdapter,
12
+ SqliteIntrospector,
13
+ SqliteQueryCompiler,
14
+ } from 'kysely';
15
+ import { SequenceMapper } from '../client-role/sequence-mapper';
16
+ import { ensureRelaySchema } from '../migrate';
17
+ import { ModeManager, type RelayMode } from '../mode-manager';
18
+ import { createRelayWebSocketConnection, RelayRealtime } from '../realtime';
19
+ import type { RelayDatabase } from '../schema';
20
+
21
+ // Helper to create in-memory SQLite database
22
+ function createTestDb() {
23
+ const sqlite = new Database(':memory:');
24
+
25
+ const dialect: Dialect = {
26
+ createAdapter: () => new SqliteAdapter(),
27
+ createDriver: () => ({
28
+ init: async () => {},
29
+ acquireConnection: async () => ({
30
+ executeQuery: async <R>(compiledQuery: {
31
+ sql: string;
32
+ parameters: readonly unknown[];
33
+ }): Promise<QueryResult<R>> => {
34
+ const sql = compiledQuery.sql;
35
+ const params = compiledQuery.parameters ?? [];
36
+
37
+ const normalizedSql = sql.trimStart().toLowerCase();
38
+ if (
39
+ normalizedSql.startsWith('select') ||
40
+ normalizedSql.startsWith('with') ||
41
+ normalizedSql.startsWith('pragma')
42
+ ) {
43
+ const stmt = sqlite.prepare(sql);
44
+ return { rows: stmt.all(...params) as R[] };
45
+ }
46
+
47
+ const stmt = sqlite.prepare(sql);
48
+ const result = stmt.run(...params);
49
+ return {
50
+ rows: [] as R[],
51
+ numAffectedRows: BigInt(result.changes),
52
+ insertId:
53
+ result.lastInsertRowid != null
54
+ ? BigInt(result.lastInsertRowid)
55
+ : undefined,
56
+ };
57
+ },
58
+ streamQuery: <R>(): AsyncIterableIterator<QueryResult<R>> => {
59
+ throw new Error('Not implemented');
60
+ },
61
+ }),
62
+ beginTransaction: async () => {},
63
+ commitTransaction: async () => {},
64
+ rollbackTransaction: async () => {},
65
+ releaseConnection: async () => {},
66
+ destroy: async () => {},
67
+ }),
68
+ createIntrospector: (db) => new SqliteIntrospector(db),
69
+ createQueryCompiler: () => new SqliteQueryCompiler(),
70
+ };
71
+
72
+ const db = new Kysely<RelayDatabase>({ dialect });
73
+
74
+ return { db, sqlite };
75
+ }
76
+
77
+ describe('ModeManager', () => {
78
+ it('should start in offline mode', () => {
79
+ const manager = new ModeManager();
80
+ expect(manager.getMode()).toBe('offline');
81
+ });
82
+
83
+ it('should transition to online on success', async () => {
84
+ const modes: RelayMode[] = [];
85
+ const manager = new ModeManager({
86
+ healthCheckIntervalMs: 100,
87
+ onModeChange: (mode) => modes.push(mode),
88
+ });
89
+
90
+ manager.start(async () => true);
91
+
92
+ // Wait for health check
93
+ await new Promise((resolve) => setTimeout(resolve, 150));
94
+
95
+ expect(manager.getMode()).toBe('online');
96
+ expect(modes).toContain('online');
97
+
98
+ manager.stop();
99
+ });
100
+
101
+ it('should transition to reconnecting on failure', async () => {
102
+ const modes: RelayMode[] = [];
103
+ const manager = new ModeManager({
104
+ healthCheckIntervalMs: 100,
105
+ reconnectBackoffMs: 50,
106
+ onModeChange: (mode) => modes.push(mode),
107
+ });
108
+
109
+ // Start online
110
+ manager.start(async () => true);
111
+ await new Promise((resolve) => setTimeout(resolve, 150));
112
+ expect(manager.getMode()).toBe('online');
113
+
114
+ // Report failure
115
+ manager.reportFailure();
116
+ expect(manager.getMode()).toBe('reconnecting');
117
+ expect(modes).toContain('reconnecting');
118
+
119
+ manager.stop();
120
+ });
121
+
122
+ it('should reset backoff on success', () => {
123
+ const manager = new ModeManager({
124
+ reconnectBackoffMs: 1000,
125
+ });
126
+
127
+ // Report multiple failures to increase backoff
128
+ manager.reportFailure();
129
+ manager.reportFailure();
130
+ manager.reportFailure();
131
+
132
+ // Success should reset
133
+ manager.reportSuccess();
134
+ expect(manager.getMode()).toBe('online');
135
+ });
136
+ });
137
+
138
+ describe('RelayRealtime', () => {
139
+ it('should start with no connections', () => {
140
+ const realtime = new RelayRealtime();
141
+ expect(realtime.getTotalConnections()).toBe(0);
142
+ });
143
+
144
+ it('should register and unregister connections', () => {
145
+ const realtime = new RelayRealtime({ heartbeatIntervalMs: 0 });
146
+
147
+ const mockWs = {
148
+ send: () => {},
149
+ close: () => {},
150
+ readyState: 1,
151
+ };
152
+
153
+ const conn = createRelayWebSocketConnection(mockWs, {
154
+ actorId: 'actor1',
155
+ clientId: 'client1',
156
+ });
157
+
158
+ const unregister = realtime.register(conn, ['scope:test']);
159
+ expect(realtime.getTotalConnections()).toBe(1);
160
+ expect(realtime.getConnectionCount('client1')).toBe(1);
161
+
162
+ unregister();
163
+ expect(realtime.getTotalConnections()).toBe(0);
164
+ expect(realtime.getConnectionCount('client1')).toBe(0);
165
+ });
166
+
167
+ it('should update client scopes', () => {
168
+ const realtime = new RelayRealtime({ heartbeatIntervalMs: 0 });
169
+
170
+ const mockWs = {
171
+ send: () => {},
172
+ close: () => {},
173
+ readyState: 1,
174
+ };
175
+
176
+ const conn = createRelayWebSocketConnection(mockWs, {
177
+ actorId: 'actor1',
178
+ clientId: 'client1',
179
+ });
180
+
181
+ realtime.register(conn, ['scope:a']);
182
+ realtime.updateClientScopeKeys('client1', ['scope:a', 'scope:b']);
183
+
184
+ // Should still have 1 connection
185
+ expect(realtime.getTotalConnections()).toBe(1);
186
+
187
+ realtime.closeAll();
188
+ });
189
+
190
+ it('should notify connections by scope', () => {
191
+ const realtime = new RelayRealtime({ heartbeatIntervalMs: 0 });
192
+
193
+ const messages1: string[] = [];
194
+ const messages2: string[] = [];
195
+
196
+ const mockWs1 = {
197
+ send: (msg: string) => messages1.push(msg),
198
+ close: () => {},
199
+ readyState: 1,
200
+ };
201
+
202
+ const mockWs2 = {
203
+ send: (msg: string) => messages2.push(msg),
204
+ close: () => {},
205
+ readyState: 1,
206
+ };
207
+
208
+ const conn1 = createRelayWebSocketConnection(mockWs1, {
209
+ actorId: 'actor1',
210
+ clientId: 'client1',
211
+ });
212
+
213
+ const conn2 = createRelayWebSocketConnection(mockWs2, {
214
+ actorId: 'actor2',
215
+ clientId: 'client2',
216
+ });
217
+
218
+ realtime.register(conn1, ['scope:a']);
219
+ realtime.register(conn2, ['scope:b']);
220
+
221
+ // Notify scope:a - only client1 should receive
222
+ realtime.notifyScopeKeys(['scope:a'], 42);
223
+ expect(messages1.length).toBe(1);
224
+ expect(messages2.length).toBe(0);
225
+
226
+ const parsed = JSON.parse(messages1[0]);
227
+ expect(parsed.event).toBe('sync');
228
+ expect(parsed.data.cursor).toBe(42);
229
+
230
+ realtime.closeAll();
231
+ });
232
+
233
+ it('should exclude specified client IDs from notifications', () => {
234
+ const realtime = new RelayRealtime({ heartbeatIntervalMs: 0 });
235
+
236
+ const messages1: string[] = [];
237
+ const messages2: string[] = [];
238
+
239
+ const conn1 = createRelayWebSocketConnection(
240
+ {
241
+ send: (msg: string) => messages1.push(msg),
242
+ close: () => {},
243
+ readyState: 1,
244
+ },
245
+ { actorId: 'actor1', clientId: 'client1' }
246
+ );
247
+
248
+ const conn2 = createRelayWebSocketConnection(
249
+ {
250
+ send: (msg: string) => messages2.push(msg),
251
+ close: () => {},
252
+ readyState: 1,
253
+ },
254
+ { actorId: 'actor2', clientId: 'client2' }
255
+ );
256
+
257
+ realtime.register(conn1, ['scope:shared']);
258
+ realtime.register(conn2, ['scope:shared']);
259
+
260
+ // Notify but exclude client1
261
+ realtime.notifyScopeKeys(['scope:shared'], 100, {
262
+ excludeClientIds: ['client1'],
263
+ });
264
+
265
+ expect(messages1.length).toBe(0);
266
+ expect(messages2.length).toBe(1);
267
+
268
+ realtime.closeAll();
269
+ });
270
+ });
271
+
272
+ describe('SequenceMapper', () => {
273
+ let db: Kysely<RelayDatabase>;
274
+ let sqlite: Database;
275
+ let mapper: SequenceMapper<RelayDatabase>;
276
+
277
+ beforeEach(async () => {
278
+ const testDb = createTestDb();
279
+ db = testDb.db;
280
+ sqlite = testDb.sqlite;
281
+
282
+ const dialect = createSqliteServerDialect();
283
+ await ensureRelaySchema(db, dialect);
284
+
285
+ mapper = new SequenceMapper({ db });
286
+ });
287
+
288
+ afterEach(async () => {
289
+ await db.destroy();
290
+ sqlite.close();
291
+ });
292
+
293
+ it('should create pending mappings', async () => {
294
+ await mapper.createPendingMapping(1);
295
+
296
+ const mapping = await mapper.getMapping(1);
297
+ expect(mapping).not.toBeNull();
298
+ expect(mapping?.localCommitSeq).toBe(1);
299
+ expect(mapping?.mainCommitSeq).toBeNull();
300
+ expect(mapping?.status).toBe('pending');
301
+ });
302
+
303
+ it('should mark mappings as forwarded', async () => {
304
+ await mapper.createPendingMapping(1);
305
+ await mapper.markForwarded(1, 100);
306
+
307
+ const mapping = await mapper.getMapping(1);
308
+ expect(mapping?.mainCommitSeq).toBe(100);
309
+ expect(mapping?.status).toBe('forwarded');
310
+ });
311
+
312
+ it('should mark mappings as confirmed', async () => {
313
+ await mapper.createPendingMapping(1);
314
+ await mapper.markForwarded(1, 100);
315
+ await mapper.markConfirmed(1);
316
+
317
+ const mapping = await mapper.getMapping(1);
318
+ expect(mapping?.status).toBe('confirmed');
319
+ });
320
+
321
+ it('should get local commit seq from main commit seq', async () => {
322
+ await mapper.createPendingMapping(5);
323
+ await mapper.markForwarded(5, 500);
324
+
325
+ const localSeq = await mapper.getLocalCommitSeq(500);
326
+ expect(localSeq).toBe(5);
327
+ });
328
+
329
+ it('should return null for unknown main commit seq', async () => {
330
+ const localSeq = await mapper.getLocalCommitSeq(999);
331
+ expect(localSeq).toBeNull();
332
+ });
333
+
334
+ it('should create confirmed mappings for pulled commits', async () => {
335
+ await mapper.createConfirmedMapping(10, 1000);
336
+
337
+ const mapping = await mapper.getMapping(10);
338
+ expect(mapping?.localCommitSeq).toBe(10);
339
+ expect(mapping?.mainCommitSeq).toBe(1000);
340
+ expect(mapping?.status).toBe('confirmed');
341
+ });
342
+
343
+ it('should get pending mappings', async () => {
344
+ await mapper.createPendingMapping(1);
345
+ await mapper.createPendingMapping(2);
346
+ await mapper.createPendingMapping(3);
347
+ await mapper.markForwarded(2, 200);
348
+
349
+ const pending = await mapper.getPendingMappings();
350
+ expect(pending.length).toBe(2);
351
+ expect(pending[0].localCommitSeq).toBe(1);
352
+ expect(pending[1].localCommitSeq).toBe(3);
353
+ });
354
+
355
+ it('should get highest main commit seq', async () => {
356
+ await mapper.createConfirmedMapping(1, 100);
357
+ await mapper.createConfirmedMapping(2, 200);
358
+ await mapper.createConfirmedMapping(3, 150);
359
+
360
+ const highest = await mapper.getHighestMainCommitSeq();
361
+ expect(highest).toBe(200);
362
+ });
363
+
364
+ it('should return 0 for highest main commit seq when empty', async () => {
365
+ const highest = await mapper.getHighestMainCommitSeq();
366
+ expect(highest).toBe(0);
367
+ });
368
+ });
369
+
370
+ describe('createRelayWebSocketConnection', () => {
371
+ it('should create connection with correct properties', () => {
372
+ const mockWs = {
373
+ send: () => {},
374
+ close: () => {},
375
+ readyState: 1,
376
+ };
377
+
378
+ const conn = createRelayWebSocketConnection(mockWs, {
379
+ actorId: 'actor1',
380
+ clientId: 'client1',
381
+ });
382
+
383
+ expect(conn.actorId).toBe('actor1');
384
+ expect(conn.clientId).toBe('client1');
385
+ expect(conn.isOpen).toBe(true);
386
+ });
387
+
388
+ it('should report closed when readyState is not 1', () => {
389
+ const mockWs = {
390
+ send: () => {},
391
+ close: () => {},
392
+ readyState: 3, // CLOSED
393
+ };
394
+
395
+ const conn = createRelayWebSocketConnection(mockWs, {
396
+ actorId: 'actor1',
397
+ clientId: 'client1',
398
+ });
399
+
400
+ expect(conn.isOpen).toBe(false);
401
+ });
402
+
403
+ it('should send sync events', () => {
404
+ const messages: string[] = [];
405
+ const mockWs = {
406
+ send: (msg: string) => messages.push(msg),
407
+ close: () => {},
408
+ readyState: 1,
409
+ };
410
+
411
+ const conn = createRelayWebSocketConnection(mockWs, {
412
+ actorId: 'actor1',
413
+ clientId: 'client1',
414
+ });
415
+
416
+ conn.sendSync(42);
417
+
418
+ expect(messages.length).toBe(1);
419
+ const parsed = JSON.parse(messages[0]);
420
+ expect(parsed.event).toBe('sync');
421
+ expect(parsed.data.cursor).toBe(42);
422
+ expect(typeof parsed.data.timestamp).toBe('number');
423
+ });
424
+
425
+ it('should send heartbeat events', () => {
426
+ const messages: string[] = [];
427
+ const mockWs = {
428
+ send: (msg: string) => messages.push(msg),
429
+ close: () => {},
430
+ readyState: 1,
431
+ };
432
+
433
+ const conn = createRelayWebSocketConnection(mockWs, {
434
+ actorId: 'actor1',
435
+ clientId: 'client1',
436
+ });
437
+
438
+ conn.sendHeartbeat();
439
+
440
+ expect(messages.length).toBe(1);
441
+ const parsed = JSON.parse(messages[0]);
442
+ expect(parsed.event).toBe('heartbeat');
443
+ expect(typeof parsed.data.timestamp).toBe('number');
444
+ });
445
+
446
+ it('should not send when closed', () => {
447
+ const messages: string[] = [];
448
+ const mockWs = {
449
+ send: (msg: string) => messages.push(msg),
450
+ close: () => {},
451
+ readyState: 1,
452
+ };
453
+
454
+ const conn = createRelayWebSocketConnection(mockWs, {
455
+ actorId: 'actor1',
456
+ clientId: 'client1',
457
+ });
458
+
459
+ conn.close();
460
+ conn.sendSync(42);
461
+
462
+ expect(messages.length).toBe(0);
463
+ });
464
+ });
@@ -0,0 +1,50 @@
1
+ // Type declarations for bun modules when running tsgo
2
+ declare module 'bun:sqlite' {
3
+ export default class Database {
4
+ constructor(path: string);
5
+ close(): void;
6
+ exec(sql: string): void;
7
+ query<T = unknown>(sql: string): Statement<T>;
8
+ prepare<T = unknown>(sql: string): Statement<T>;
9
+ transaction<T>(fn: () => T): () => T;
10
+ }
11
+
12
+ export class Statement<T = unknown> {
13
+ run(
14
+ ...params: unknown[]
15
+ ): { changes: number; lastInsertRowid: number | bigint | null };
16
+ get(...params: unknown[]): T | null;
17
+ all(...params: unknown[]): T[];
18
+ values(...params: unknown[]): unknown[][];
19
+ finalize(): void;
20
+ }
21
+ }
22
+
23
+ declare module 'bun:test' {
24
+ export function describe(name: string, fn: () => void): void;
25
+ export function it(name: string, fn: () => void | Promise<void>): void;
26
+ export function test(name: string, fn: () => void | Promise<void>): void;
27
+ export function beforeEach(fn: () => void | Promise<void>): void;
28
+ export function afterEach(fn: () => void | Promise<void>): void;
29
+ export function beforeAll(fn: () => void | Promise<void>): void;
30
+ export function afterAll(fn: () => void | Promise<void>): void;
31
+ export function expect<T>(value: T): Matchers<T>;
32
+
33
+ interface Matchers<T> {
34
+ toBe(expected: unknown): void;
35
+ toEqual(expected: unknown): void;
36
+ toBeTruthy(): void;
37
+ toBeFalsy(): void;
38
+ toBeNull(): void;
39
+ toBeUndefined(): void;
40
+ toBeDefined(): void;
41
+ toBeGreaterThan(expected: number): void;
42
+ toBeGreaterThanOrEqual(expected: number): void;
43
+ toBeLessThan(expected: number): void;
44
+ toBeLessThanOrEqual(expected: number): void;
45
+ toContain(expected: unknown): void;
46
+ toHaveLength(expected: number): void;
47
+ toThrow(expected?: string | RegExp | Error): void;
48
+ not: Matchers<T>;
49
+ }
50
+ }