@syncular/relay 0.0.1-100

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 +257 -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 +247 -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 +189 -0
  34. package/dist/relay.d.ts.map +1 -0
  35. package/dist/relay.js +319 -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 +195 -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 +94 -0
  52. package/dist/server-role/push.js.map +1 -0
  53. package/package.json +61 -0
  54. package/src/__tests__/relay.test.ts +781 -0
  55. package/src/bun-types.d.ts +50 -0
  56. package/src/client-role/forward-engine.ts +343 -0
  57. package/src/client-role/index.ts +9 -0
  58. package/src/client-role/pull-engine.ts +321 -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 +424 -0
  65. package/src/schema.ts +171 -0
  66. package/src/server-role/index.ts +339 -0
  67. package/src/server-role/pull.ts +37 -0
  68. package/src/server-role/push.ts +123 -0
@@ -0,0 +1,781 @@
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 type { SyncCombinedRequest, SyncCombinedResponse } from '@syncular/core';
8
+ import { TableRegistry } from '@syncular/server';
9
+ import { createSqliteServerDialect } from '@syncular/server-dialect-sqlite';
10
+ import type { Dialect, QueryResult } from 'kysely';
11
+ import {
12
+ Kysely,
13
+ SqliteAdapter,
14
+ SqliteIntrospector,
15
+ SqliteQueryCompiler,
16
+ sql,
17
+ } from 'kysely';
18
+ import { PullEngine } from '../client-role/pull-engine';
19
+ import { SequenceMapper } from '../client-role/sequence-mapper';
20
+ import { ensureRelaySchema } from '../migrate';
21
+ import { ModeManager, type RelayMode } from '../mode-manager';
22
+ import { createRelayWebSocketConnection, RelayRealtime } from '../realtime';
23
+ import { RelayServer } from '../relay';
24
+ import type { RelayDatabase } from '../schema';
25
+ import { relayPushCommit } from '../server-role/push';
26
+
27
+ // Helper to create in-memory SQLite database
28
+ function createTestDb() {
29
+ const sqlite = new Database(':memory:');
30
+
31
+ const dialect: Dialect = {
32
+ createAdapter: () => new SqliteAdapter(),
33
+ createDriver: () => ({
34
+ init: async () => {},
35
+ acquireConnection: async () => ({
36
+ executeQuery: async <R>(compiledQuery: {
37
+ sql: string;
38
+ parameters: readonly unknown[];
39
+ }): Promise<QueryResult<R>> => {
40
+ const sql = compiledQuery.sql;
41
+ const params = compiledQuery.parameters ?? [];
42
+
43
+ const normalizedSql = sql.trimStart().toLowerCase();
44
+ if (
45
+ normalizedSql.startsWith('select') ||
46
+ normalizedSql.startsWith('with') ||
47
+ normalizedSql.startsWith('pragma')
48
+ ) {
49
+ const stmt = sqlite.prepare(sql);
50
+ return { rows: stmt.all(...params) as R[] };
51
+ }
52
+
53
+ const stmt = sqlite.prepare(sql);
54
+ const result = stmt.run(...params);
55
+ return {
56
+ rows: [] as R[],
57
+ numAffectedRows: BigInt(result.changes),
58
+ insertId:
59
+ result.lastInsertRowid != null
60
+ ? BigInt(result.lastInsertRowid)
61
+ : undefined,
62
+ };
63
+ },
64
+ streamQuery: <R>(): AsyncIterableIterator<QueryResult<R>> => {
65
+ throw new Error('Not implemented');
66
+ },
67
+ }),
68
+ beginTransaction: async () => {},
69
+ commitTransaction: async () => {},
70
+ rollbackTransaction: async () => {},
71
+ releaseConnection: async () => {},
72
+ destroy: async () => {},
73
+ }),
74
+ createIntrospector: (db) => new SqliteIntrospector(db),
75
+ createQueryCompiler: () => new SqliteQueryCompiler(),
76
+ };
77
+
78
+ const db = new Kysely<RelayDatabase>({ dialect });
79
+
80
+ return { db, sqlite };
81
+ }
82
+
83
+ describe('ModeManager', () => {
84
+ it('should start in offline mode', () => {
85
+ const manager = new ModeManager();
86
+ expect(manager.getMode()).toBe('offline');
87
+ });
88
+
89
+ it('should transition to online on success', async () => {
90
+ const modes: RelayMode[] = [];
91
+ const manager = new ModeManager({
92
+ healthCheckIntervalMs: 100,
93
+ onModeChange: (mode) => modes.push(mode),
94
+ });
95
+
96
+ manager.start(async () => true);
97
+
98
+ // Wait for health check
99
+ await new Promise((resolve) => setTimeout(resolve, 150));
100
+
101
+ expect(manager.getMode()).toBe('online');
102
+ expect(modes).toContain('online');
103
+
104
+ manager.stop();
105
+ });
106
+
107
+ it('should transition to reconnecting on failure', async () => {
108
+ const modes: RelayMode[] = [];
109
+ const manager = new ModeManager({
110
+ healthCheckIntervalMs: 100,
111
+ reconnectBackoffMs: 50,
112
+ onModeChange: (mode) => modes.push(mode),
113
+ });
114
+
115
+ // Start online
116
+ manager.start(async () => true);
117
+ await new Promise((resolve) => setTimeout(resolve, 150));
118
+ expect(manager.getMode()).toBe('online');
119
+
120
+ // Report failure
121
+ manager.reportFailure();
122
+ expect(manager.getMode()).toBe('reconnecting');
123
+ expect(modes).toContain('reconnecting');
124
+
125
+ manager.stop();
126
+ });
127
+
128
+ it('should reset backoff on success', () => {
129
+ const manager = new ModeManager({
130
+ reconnectBackoffMs: 1000,
131
+ });
132
+
133
+ // Report multiple failures to increase backoff
134
+ manager.reportFailure();
135
+ manager.reportFailure();
136
+ manager.reportFailure();
137
+
138
+ // Success should reset
139
+ manager.reportSuccess();
140
+ expect(manager.getMode()).toBe('online');
141
+ });
142
+
143
+ it('should transition from offline to reconnecting on initial failure', async () => {
144
+ const manager = new ModeManager({
145
+ healthCheckIntervalMs: 100,
146
+ reconnectBackoffMs: 50,
147
+ });
148
+
149
+ manager.start(async () => false);
150
+ await new Promise((resolve) => setTimeout(resolve, 20));
151
+
152
+ expect(manager.getMode()).toBe('reconnecting');
153
+ manager.stop();
154
+ });
155
+ });
156
+
157
+ describe('RelayServer health check', () => {
158
+ let db: Kysely<RelayDatabase>;
159
+ let sqlite: Database;
160
+
161
+ beforeEach(() => {
162
+ const setup = createTestDb();
163
+ db = setup.db;
164
+ sqlite = setup.sqlite;
165
+ });
166
+
167
+ afterEach(async () => {
168
+ await db.destroy();
169
+ sqlite.close();
170
+ });
171
+
172
+ it('uses a protocol-valid pull limit during startup health checks', async () => {
173
+ const syncCalls: SyncCombinedRequest[] = [];
174
+ const mainServerTransport = {
175
+ async sync(request: SyncCombinedRequest): Promise<SyncCombinedResponse> {
176
+ syncCalls.push(request);
177
+ return {};
178
+ },
179
+ async fetchSnapshotChunk(): Promise<Uint8Array> {
180
+ return new Uint8Array();
181
+ },
182
+ };
183
+
184
+ const relay = new RelayServer({
185
+ db,
186
+ dialect: createSqliteServerDialect(),
187
+ mainServerTransport,
188
+ mainServerClientId: 'relay-main-client',
189
+ mainServerActorId: 'relay-main-actor',
190
+ tables: [],
191
+ scopes: {},
192
+ shapes: new TableRegistry<RelayDatabase>(),
193
+ healthCheckIntervalMs: 50,
194
+ pullIntervalMs: 10_000,
195
+ forwardRetryIntervalMs: 10_000,
196
+ pruneIntervalMs: 0,
197
+ });
198
+
199
+ await relay.start();
200
+ await new Promise((resolve) => setTimeout(resolve, 25));
201
+ await relay.stop();
202
+
203
+ expect(
204
+ syncCalls.some(
205
+ (request) =>
206
+ request.pull?.subscriptions.length === 0 &&
207
+ request.pull.limitCommits === 1
208
+ )
209
+ ).toBe(true);
210
+ });
211
+ });
212
+
213
+ describe('relayPushCommit atomic enqueue', () => {
214
+ let db: Kysely<RelayDatabase>;
215
+ let sqlite: Database;
216
+
217
+ beforeEach(async () => {
218
+ const setup = createTestDb();
219
+ db = setup.db;
220
+ sqlite = setup.sqlite;
221
+ await ensureRelaySchema(db, createSqliteServerDialect());
222
+ });
223
+
224
+ afterEach(async () => {
225
+ await db.destroy();
226
+ sqlite.close();
227
+ });
228
+
229
+ it('rolls back local commit when forwarding enqueue fails', async () => {
230
+ const now = Date.now();
231
+ await sql`
232
+ insert into ${sql.table('relay_forward_outbox')} (
233
+ id,
234
+ local_commit_seq,
235
+ client_id,
236
+ client_commit_id,
237
+ operations_json,
238
+ schema_version,
239
+ status,
240
+ main_commit_seq,
241
+ error,
242
+ last_response_json,
243
+ created_at,
244
+ updated_at,
245
+ attempt_count
246
+ )
247
+ values (
248
+ ${'fixed-outbox-id'},
249
+ ${999},
250
+ ${'seed-client'},
251
+ ${'seed-commit'},
252
+ ${'[]'},
253
+ ${1},
254
+ ${'pending'},
255
+ ${null},
256
+ ${null},
257
+ ${null},
258
+ ${now},
259
+ ${now},
260
+ ${0}
261
+ )
262
+ `.execute(db);
263
+
264
+ const shapes = new TableRegistry<RelayDatabase>();
265
+ shapes.register({
266
+ table: 'tasks',
267
+ scopePatterns: ['user:{user_id}'],
268
+ resolveScopes: async () => ({ user_id: ['u1'] }),
269
+ extractScopes: () => ({ user_id: 'u1' }),
270
+ snapshot: async () => ({ rows: [], nextCursor: null }),
271
+ async applyOperation(_ctx, op, opIndex) {
272
+ return {
273
+ result: {
274
+ opIndex,
275
+ status: 'applied',
276
+ },
277
+ emittedChanges: [
278
+ {
279
+ table: 'tasks',
280
+ row_id: op.row_id,
281
+ op: op.op,
282
+ row_json: op.payload,
283
+ row_version: 1,
284
+ scopes: { user_id: 'u1' },
285
+ },
286
+ ],
287
+ };
288
+ },
289
+ });
290
+
291
+ const originalRandomUUID = crypto.randomUUID;
292
+ crypto.randomUUID = () => 'fixed-outbox-id';
293
+ try {
294
+ await expect(
295
+ relayPushCommit({
296
+ db,
297
+ dialect: createSqliteServerDialect(),
298
+ shapes,
299
+ actorId: 'u1',
300
+ request: {
301
+ clientId: 'relay-client-1',
302
+ clientCommitId: 'relay-commit-1',
303
+ schemaVersion: 1,
304
+ operations: [
305
+ {
306
+ table: 'tasks',
307
+ row_id: 'task-1',
308
+ op: 'upsert',
309
+ payload: { id: 'task-1', title: 'hello' },
310
+ base_version: null,
311
+ },
312
+ ],
313
+ },
314
+ })
315
+ ).rejects.toThrow();
316
+ } finally {
317
+ crypto.randomUUID = originalRandomUUID;
318
+ }
319
+
320
+ const commitRows = await sql<{ count: number | bigint }>`
321
+ select count(*) as count
322
+ from ${sql.table('sync_commits')}
323
+ where ${sql.ref('client_id')} = ${'relay-client-1'}
324
+ and ${sql.ref('client_commit_id')} = ${'relay-commit-1'}
325
+ `.execute(db);
326
+ expect(Number(commitRows.rows[0]?.count ?? 0)).toBe(0);
327
+ });
328
+ });
329
+
330
+ describe('PullEngine cursor safety', () => {
331
+ let db: Kysely<RelayDatabase>;
332
+ let sqlite: Database;
333
+
334
+ beforeEach(async () => {
335
+ const setup = createTestDb();
336
+ db = setup.db;
337
+ sqlite = setup.sqlite;
338
+ await ensureRelaySchema(db, createSqliteServerDialect());
339
+ });
340
+
341
+ afterEach(async () => {
342
+ await db.destroy();
343
+ sqlite.close();
344
+ });
345
+
346
+ it('does not advance cursor when a pulled commit is rejected locally', async () => {
347
+ const syncCalls: SyncCombinedRequest[] = [];
348
+ const transport = {
349
+ async sync(request: SyncCombinedRequest): Promise<SyncCombinedResponse> {
350
+ syncCalls.push(request);
351
+ return {
352
+ pull: {
353
+ ok: true,
354
+ subscriptions: [
355
+ {
356
+ id: 'tasks',
357
+ status: 'active',
358
+ scopes: {},
359
+ bootstrap: false,
360
+ nextCursor: 25,
361
+ commits: [
362
+ {
363
+ commitSeq: 25,
364
+ createdAt: new Date(0).toISOString(),
365
+ actorId: 'main-actor',
366
+ changes: [
367
+ {
368
+ table: 'tasks',
369
+ row_id: 'task-1',
370
+ op: 'upsert',
371
+ row_json: { id: 'task-1', title: 'from-main' },
372
+ row_version: 1,
373
+ scopes: {},
374
+ },
375
+ ],
376
+ },
377
+ ],
378
+ },
379
+ ],
380
+ },
381
+ };
382
+ },
383
+ async fetchSnapshotChunk(): Promise<Uint8Array> {
384
+ return new Uint8Array();
385
+ },
386
+ };
387
+
388
+ const shapes = new TableRegistry<RelayDatabase>();
389
+ shapes.register({
390
+ table: 'tasks',
391
+ scopePatterns: ['user:{user_id}'],
392
+ resolveScopes: async () => ({}),
393
+ extractScopes: () => ({}),
394
+ snapshot: async () => ({ rows: [], nextCursor: null }),
395
+ async applyOperation(_ctx, _op, opIndex) {
396
+ return {
397
+ result: {
398
+ opIndex,
399
+ status: 'conflict',
400
+ message: 'reject locally',
401
+ server_version: 1,
402
+ server_row: {},
403
+ },
404
+ emittedChanges: [],
405
+ };
406
+ },
407
+ });
408
+
409
+ const pullErrors: Error[] = [];
410
+ const pullEngine = new PullEngine({
411
+ db,
412
+ dialect: createSqliteServerDialect(),
413
+ transport,
414
+ clientId: 'relay-client',
415
+ tables: ['tasks'],
416
+ scopes: {},
417
+ shapes,
418
+ sequenceMapper: new SequenceMapper({ db }),
419
+ realtime: new RelayRealtime({ heartbeatIntervalMs: 0 }),
420
+ onError: (error) => pullErrors.push(error),
421
+ });
422
+
423
+ await pullEngine.pullOnce();
424
+ await pullEngine.pullOnce();
425
+
426
+ expect(pullErrors.length).toBe(2);
427
+ expect(syncCalls.length).toBe(2);
428
+ expect(syncCalls[0]?.pull?.subscriptions[0]?.cursor).toBe(-1);
429
+ expect(syncCalls[1]?.pull?.subscriptions[0]?.cursor).toBe(-1);
430
+
431
+ const rowResult = await sql<{ value_json: string }>`
432
+ select value_json
433
+ from ${sql.table('relay_config')}
434
+ where key = 'main_cursors'
435
+ limit 1
436
+ `.execute(db);
437
+ const row = rowResult.rows[0];
438
+
439
+ const cursorState: Record<string, number> = {};
440
+ if (row?.value_json) {
441
+ const parsed = JSON.parse(row.value_json);
442
+ if (typeof parsed === 'object' && parsed !== null) {
443
+ for (const [key, value] of Object.entries(parsed)) {
444
+ if (typeof value === 'number') {
445
+ cursorState[key] = value;
446
+ }
447
+ }
448
+ }
449
+ }
450
+
451
+ expect(cursorState.tasks).toBeUndefined();
452
+ });
453
+ });
454
+
455
+ describe('RelayRealtime', () => {
456
+ it('should start with no connections', () => {
457
+ const realtime = new RelayRealtime();
458
+ expect(realtime.getTotalConnections()).toBe(0);
459
+ });
460
+
461
+ it('should register and unregister connections', () => {
462
+ const realtime = new RelayRealtime({ heartbeatIntervalMs: 0 });
463
+
464
+ const mockWs = {
465
+ send: () => {},
466
+ close: () => {},
467
+ readyState: 1,
468
+ };
469
+
470
+ const conn = createRelayWebSocketConnection(mockWs, {
471
+ actorId: 'actor1',
472
+ clientId: 'client1',
473
+ });
474
+
475
+ const unregister = realtime.register(conn, ['scope:test']);
476
+ expect(realtime.getTotalConnections()).toBe(1);
477
+ expect(realtime.getConnectionCount('client1')).toBe(1);
478
+
479
+ unregister();
480
+ expect(realtime.getTotalConnections()).toBe(0);
481
+ expect(realtime.getConnectionCount('client1')).toBe(0);
482
+ });
483
+
484
+ it('should update client scopes', () => {
485
+ const realtime = new RelayRealtime({ heartbeatIntervalMs: 0 });
486
+
487
+ const mockWs = {
488
+ send: () => {},
489
+ close: () => {},
490
+ readyState: 1,
491
+ };
492
+
493
+ const conn = createRelayWebSocketConnection(mockWs, {
494
+ actorId: 'actor1',
495
+ clientId: 'client1',
496
+ });
497
+
498
+ realtime.register(conn, ['scope:a']);
499
+ realtime.updateClientScopeKeys('client1', ['scope:a', 'scope:b']);
500
+
501
+ // Should still have 1 connection
502
+ expect(realtime.getTotalConnections()).toBe(1);
503
+
504
+ realtime.closeAll();
505
+ });
506
+
507
+ it('should notify connections by scope', () => {
508
+ const realtime = new RelayRealtime({ heartbeatIntervalMs: 0 });
509
+
510
+ const messages1: string[] = [];
511
+ const messages2: string[] = [];
512
+
513
+ const mockWs1 = {
514
+ send: (msg: string) => messages1.push(msg),
515
+ close: () => {},
516
+ readyState: 1,
517
+ };
518
+
519
+ const mockWs2 = {
520
+ send: (msg: string) => messages2.push(msg),
521
+ close: () => {},
522
+ readyState: 1,
523
+ };
524
+
525
+ const conn1 = createRelayWebSocketConnection(mockWs1, {
526
+ actorId: 'actor1',
527
+ clientId: 'client1',
528
+ });
529
+
530
+ const conn2 = createRelayWebSocketConnection(mockWs2, {
531
+ actorId: 'actor2',
532
+ clientId: 'client2',
533
+ });
534
+
535
+ realtime.register(conn1, ['scope:a']);
536
+ realtime.register(conn2, ['scope:b']);
537
+
538
+ // Notify scope:a - only client1 should receive
539
+ realtime.notifyScopeKeys(['scope:a'], 42);
540
+ expect(messages1.length).toBe(1);
541
+ expect(messages2.length).toBe(0);
542
+
543
+ const parsed = JSON.parse(messages1[0]);
544
+ expect(parsed.event).toBe('sync');
545
+ expect(parsed.data.cursor).toBe(42);
546
+
547
+ realtime.closeAll();
548
+ });
549
+
550
+ it('should exclude specified client IDs from notifications', () => {
551
+ const realtime = new RelayRealtime({ heartbeatIntervalMs: 0 });
552
+
553
+ const messages1: string[] = [];
554
+ const messages2: string[] = [];
555
+
556
+ const conn1 = createRelayWebSocketConnection(
557
+ {
558
+ send: (msg: string) => messages1.push(msg),
559
+ close: () => {},
560
+ readyState: 1,
561
+ },
562
+ { actorId: 'actor1', clientId: 'client1' }
563
+ );
564
+
565
+ const conn2 = createRelayWebSocketConnection(
566
+ {
567
+ send: (msg: string) => messages2.push(msg),
568
+ close: () => {},
569
+ readyState: 1,
570
+ },
571
+ { actorId: 'actor2', clientId: 'client2' }
572
+ );
573
+
574
+ realtime.register(conn1, ['scope:shared']);
575
+ realtime.register(conn2, ['scope:shared']);
576
+
577
+ // Notify but exclude client1
578
+ realtime.notifyScopeKeys(['scope:shared'], 100, {
579
+ excludeClientIds: ['client1'],
580
+ });
581
+
582
+ expect(messages1.length).toBe(0);
583
+ expect(messages2.length).toBe(1);
584
+
585
+ realtime.closeAll();
586
+ });
587
+ });
588
+
589
+ describe('SequenceMapper', () => {
590
+ let db: Kysely<RelayDatabase>;
591
+ let sqlite: Database;
592
+ let mapper: SequenceMapper<RelayDatabase>;
593
+
594
+ beforeEach(async () => {
595
+ const testDb = createTestDb();
596
+ db = testDb.db;
597
+ sqlite = testDb.sqlite;
598
+
599
+ const dialect = createSqliteServerDialect();
600
+ await ensureRelaySchema(db, dialect);
601
+
602
+ mapper = new SequenceMapper({ db });
603
+ });
604
+
605
+ afterEach(async () => {
606
+ await db.destroy();
607
+ sqlite.close();
608
+ });
609
+
610
+ it('should create pending mappings', async () => {
611
+ await mapper.createPendingMapping(1);
612
+
613
+ const mapping = await mapper.getMapping(1);
614
+ expect(mapping).not.toBeNull();
615
+ expect(mapping?.localCommitSeq).toBe(1);
616
+ expect(mapping?.mainCommitSeq).toBeNull();
617
+ expect(mapping?.status).toBe('pending');
618
+ });
619
+
620
+ it('should mark mappings as forwarded', async () => {
621
+ await mapper.createPendingMapping(1);
622
+ await mapper.markForwarded(1, 100);
623
+
624
+ const mapping = await mapper.getMapping(1);
625
+ expect(mapping?.mainCommitSeq).toBe(100);
626
+ expect(mapping?.status).toBe('forwarded');
627
+ });
628
+
629
+ it('should mark mappings as confirmed', async () => {
630
+ await mapper.createPendingMapping(1);
631
+ await mapper.markForwarded(1, 100);
632
+ await mapper.markConfirmed(1);
633
+
634
+ const mapping = await mapper.getMapping(1);
635
+ expect(mapping?.status).toBe('confirmed');
636
+ });
637
+
638
+ it('should get local commit seq from main commit seq', async () => {
639
+ await mapper.createPendingMapping(5);
640
+ await mapper.markForwarded(5, 500);
641
+
642
+ const localSeq = await mapper.getLocalCommitSeq(500);
643
+ expect(localSeq).toBe(5);
644
+ });
645
+
646
+ it('should return null for unknown main commit seq', async () => {
647
+ const localSeq = await mapper.getLocalCommitSeq(999);
648
+ expect(localSeq).toBeNull();
649
+ });
650
+
651
+ it('should create confirmed mappings for pulled commits', async () => {
652
+ await mapper.createConfirmedMapping(10, 1000);
653
+
654
+ const mapping = await mapper.getMapping(10);
655
+ expect(mapping?.localCommitSeq).toBe(10);
656
+ expect(mapping?.mainCommitSeq).toBe(1000);
657
+ expect(mapping?.status).toBe('confirmed');
658
+ });
659
+
660
+ it('should get pending mappings', async () => {
661
+ await mapper.createPendingMapping(1);
662
+ await mapper.createPendingMapping(2);
663
+ await mapper.createPendingMapping(3);
664
+ await mapper.markForwarded(2, 200);
665
+
666
+ const pending = await mapper.getPendingMappings();
667
+ expect(pending.length).toBe(2);
668
+ expect(pending[0].localCommitSeq).toBe(1);
669
+ expect(pending[1].localCommitSeq).toBe(3);
670
+ });
671
+
672
+ it('should get highest main commit seq', async () => {
673
+ await mapper.createConfirmedMapping(1, 100);
674
+ await mapper.createConfirmedMapping(2, 200);
675
+ await mapper.createConfirmedMapping(3, 150);
676
+
677
+ const highest = await mapper.getHighestMainCommitSeq();
678
+ expect(highest).toBe(200);
679
+ });
680
+
681
+ it('should return 0 for highest main commit seq when empty', async () => {
682
+ const highest = await mapper.getHighestMainCommitSeq();
683
+ expect(highest).toBe(0);
684
+ });
685
+ });
686
+
687
+ describe('createRelayWebSocketConnection', () => {
688
+ it('should create connection with correct properties', () => {
689
+ const mockWs = {
690
+ send: () => {},
691
+ close: () => {},
692
+ readyState: 1,
693
+ };
694
+
695
+ const conn = createRelayWebSocketConnection(mockWs, {
696
+ actorId: 'actor1',
697
+ clientId: 'client1',
698
+ });
699
+
700
+ expect(conn.actorId).toBe('actor1');
701
+ expect(conn.clientId).toBe('client1');
702
+ expect(conn.isOpen).toBe(true);
703
+ });
704
+
705
+ it('should report closed when readyState is not 1', () => {
706
+ const mockWs = {
707
+ send: () => {},
708
+ close: () => {},
709
+ readyState: 3, // CLOSED
710
+ };
711
+
712
+ const conn = createRelayWebSocketConnection(mockWs, {
713
+ actorId: 'actor1',
714
+ clientId: 'client1',
715
+ });
716
+
717
+ expect(conn.isOpen).toBe(false);
718
+ });
719
+
720
+ it('should send sync events', () => {
721
+ const messages: string[] = [];
722
+ const mockWs = {
723
+ send: (msg: string) => messages.push(msg),
724
+ close: () => {},
725
+ readyState: 1,
726
+ };
727
+
728
+ const conn = createRelayWebSocketConnection(mockWs, {
729
+ actorId: 'actor1',
730
+ clientId: 'client1',
731
+ });
732
+
733
+ conn.sendSync(42);
734
+
735
+ expect(messages.length).toBe(1);
736
+ const parsed = JSON.parse(messages[0]);
737
+ expect(parsed.event).toBe('sync');
738
+ expect(parsed.data.cursor).toBe(42);
739
+ expect(typeof parsed.data.timestamp).toBe('number');
740
+ });
741
+
742
+ it('should send heartbeat events', () => {
743
+ const messages: string[] = [];
744
+ const mockWs = {
745
+ send: (msg: string) => messages.push(msg),
746
+ close: () => {},
747
+ readyState: 1,
748
+ };
749
+
750
+ const conn = createRelayWebSocketConnection(mockWs, {
751
+ actorId: 'actor1',
752
+ clientId: 'client1',
753
+ });
754
+
755
+ conn.sendHeartbeat();
756
+
757
+ expect(messages.length).toBe(1);
758
+ const parsed = JSON.parse(messages[0]);
759
+ expect(parsed.event).toBe('heartbeat');
760
+ expect(typeof parsed.data.timestamp).toBe('number');
761
+ });
762
+
763
+ it('should not send when closed', () => {
764
+ const messages: string[] = [];
765
+ const mockWs = {
766
+ send: (msg: string) => messages.push(msg),
767
+ close: () => {},
768
+ readyState: 1,
769
+ };
770
+
771
+ const conn = createRelayWebSocketConnection(mockWs, {
772
+ actorId: 'actor1',
773
+ clientId: 'client1',
774
+ });
775
+
776
+ conn.close();
777
+ conn.sendSync(42);
778
+
779
+ expect(messages.length).toBe(0);
780
+ });
781
+ });