@quilibrium/quorum-shared 2.1.0-1

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 (51) hide show
  1. package/dist/index.d.mts +2414 -0
  2. package/dist/index.d.ts +2414 -0
  3. package/dist/index.js +2788 -0
  4. package/dist/index.mjs +2678 -0
  5. package/package.json +49 -0
  6. package/src/api/client.ts +86 -0
  7. package/src/api/endpoints.ts +87 -0
  8. package/src/api/errors.ts +179 -0
  9. package/src/api/index.ts +35 -0
  10. package/src/crypto/encryption-state.ts +249 -0
  11. package/src/crypto/index.ts +55 -0
  12. package/src/crypto/types.ts +307 -0
  13. package/src/crypto/wasm-provider.ts +298 -0
  14. package/src/hooks/index.ts +31 -0
  15. package/src/hooks/keys.ts +62 -0
  16. package/src/hooks/mutations/index.ts +15 -0
  17. package/src/hooks/mutations/useDeleteMessage.ts +67 -0
  18. package/src/hooks/mutations/useEditMessage.ts +87 -0
  19. package/src/hooks/mutations/useReaction.ts +163 -0
  20. package/src/hooks/mutations/useSendMessage.ts +131 -0
  21. package/src/hooks/useChannels.ts +49 -0
  22. package/src/hooks/useMessages.ts +77 -0
  23. package/src/hooks/useSpaces.ts +60 -0
  24. package/src/index.ts +32 -0
  25. package/src/signing/index.ts +10 -0
  26. package/src/signing/types.ts +83 -0
  27. package/src/signing/wasm-provider.ts +75 -0
  28. package/src/storage/adapter.ts +118 -0
  29. package/src/storage/index.ts +9 -0
  30. package/src/sync/index.ts +83 -0
  31. package/src/sync/service.test.ts +822 -0
  32. package/src/sync/service.ts +947 -0
  33. package/src/sync/types.ts +267 -0
  34. package/src/sync/utils.ts +588 -0
  35. package/src/transport/browser-websocket.ts +299 -0
  36. package/src/transport/index.ts +34 -0
  37. package/src/transport/rn-websocket.ts +321 -0
  38. package/src/transport/types.ts +56 -0
  39. package/src/transport/websocket.ts +212 -0
  40. package/src/types/bookmark.ts +29 -0
  41. package/src/types/conversation.ts +25 -0
  42. package/src/types/index.ts +57 -0
  43. package/src/types/message.ts +178 -0
  44. package/src/types/space.ts +75 -0
  45. package/src/types/user.ts +72 -0
  46. package/src/utils/encoding.ts +106 -0
  47. package/src/utils/formatting.ts +139 -0
  48. package/src/utils/index.ts +9 -0
  49. package/src/utils/logger.ts +141 -0
  50. package/src/utils/mentions.ts +135 -0
  51. package/src/utils/validation.ts +84 -0
@@ -0,0 +1,822 @@
1
+ /**
2
+ * SyncService Tests
3
+ *
4
+ * Tests the sync service functionality including:
5
+ * - Payload cache management
6
+ * - buildSyncRequest
7
+ * - buildSyncInfo
8
+ * - buildSyncInitiate
9
+ * - buildSyncManifest
10
+ * - buildSyncDelta
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
14
+ import { SyncService } from './service';
15
+ import type { StorageAdapter } from '../storage';
16
+ import type { Message, SpaceMember } from '../types';
17
+ import type { SyncSummary, SyncManifest, MemberDigest } from './types';
18
+ import { createSyncSummary, createManifest, createMemberDigest, MAX_CHUNK_SIZE } from './utils';
19
+
20
+ // Mock storage adapter
21
+ function createMockStorage(
22
+ messages: Message[] = [],
23
+ members: SpaceMember[] = []
24
+ ): StorageAdapter {
25
+ return {
26
+ getMessages: vi.fn().mockResolvedValue({ messages, nextCursor: null }),
27
+ getMessage: vi.fn().mockImplementation(async ({ messageId }) => {
28
+ return messages.find((m) => m.messageId === messageId) || null;
29
+ }),
30
+ saveMessage: vi.fn().mockResolvedValue(undefined),
31
+ deleteMessage: vi.fn().mockResolvedValue(undefined),
32
+ getSpaceMembers: vi.fn().mockResolvedValue(members),
33
+ saveSpaceMember: vi.fn().mockResolvedValue(undefined),
34
+ // Add other required methods as no-ops
35
+ getConversations: vi.fn().mockResolvedValue({ conversations: [] }),
36
+ saveConversation: vi.fn().mockResolvedValue(undefined),
37
+ deleteConversation: vi.fn().mockResolvedValue(undefined),
38
+ } as unknown as StorageAdapter;
39
+ }
40
+
41
+ // Helper to create a test message
42
+ function createTestMessage(
43
+ id: string,
44
+ spaceId: string,
45
+ channelId: string,
46
+ text: string,
47
+ createdDate: number = Date.now()
48
+ ): Message {
49
+ return {
50
+ messageId: id,
51
+ spaceId,
52
+ channelId,
53
+ digestAlgorithm: 'sha256',
54
+ nonce: 'test-nonce',
55
+ createdDate,
56
+ modifiedDate: createdDate,
57
+ lastModifiedHash: 'hash',
58
+ content: {
59
+ type: 'post',
60
+ senderId: 'sender-address',
61
+ text,
62
+ },
63
+ reactions: [],
64
+ mentions: { memberIds: [], roleIds: [], channelIds: [] },
65
+ };
66
+ }
67
+
68
+ // Helper to create a test member
69
+ function createTestMember(address: string, displayName?: string): SpaceMember {
70
+ return {
71
+ address,
72
+ inbox_address: `inbox-${address}`,
73
+ display_name: displayName,
74
+ };
75
+ }
76
+
77
+ describe('SyncService', () => {
78
+ const spaceId = 'test-space-id';
79
+ const channelId = 'test-channel-id';
80
+ const inboxAddress = 'test-inbox-address';
81
+
82
+ describe('Cache Management', () => {
83
+ it('should load data from storage on first access', async () => {
84
+ const messages = [createTestMessage('msg1', spaceId, channelId, 'Hello')];
85
+ const members = [createTestMember('addr1', 'User1')];
86
+ const storage = createMockStorage(messages, members);
87
+
88
+ const service = new SyncService({ storage });
89
+
90
+ // First call should fetch from storage
91
+ const payload = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
92
+
93
+ expect(storage.getMessages).toHaveBeenCalledTimes(1);
94
+ expect(storage.getSpaceMembers).toHaveBeenCalledTimes(1);
95
+ expect(payload.summary.messageCount).toBe(1);
96
+ expect(payload.summary.memberCount).toBe(1);
97
+ });
98
+
99
+ it('should use cache on subsequent access', async () => {
100
+ const messages = [createTestMessage('msg1', spaceId, channelId, 'Hello')];
101
+ const members = [createTestMember('addr1', 'User1')];
102
+ const storage = createMockStorage(messages, members);
103
+
104
+ const service = new SyncService({ storage });
105
+
106
+ // First call
107
+ await service.buildSyncRequest(spaceId, channelId, inboxAddress);
108
+ // Second call should use cache
109
+ await service.buildSyncRequest(spaceId, channelId, inboxAddress);
110
+
111
+ // Storage should only be called once
112
+ expect(storage.getMessages).toHaveBeenCalledTimes(1);
113
+ expect(storage.getSpaceMembers).toHaveBeenCalledTimes(1);
114
+ });
115
+
116
+ it('should update cache with new message without storage query', async () => {
117
+ const messages = [createTestMessage('msg1', spaceId, channelId, 'Hello')];
118
+ const members = [createTestMember('addr1', 'User1')];
119
+ const storage = createMockStorage(messages, members);
120
+
121
+ const service = new SyncService({ storage });
122
+
123
+ // Initialize cache
124
+ const payload1 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
125
+ expect(payload1.summary.messageCount).toBe(1);
126
+
127
+ // Update cache with new message
128
+ const newMessage = createTestMessage('msg2', spaceId, channelId, 'World');
129
+ service.updateCacheWithMessage(spaceId, channelId, newMessage);
130
+
131
+ // Get payload again - should reflect new message without storage query
132
+ const payload2 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
133
+ expect(payload2.summary.messageCount).toBe(2);
134
+
135
+ // Storage should still only be called once (initial load)
136
+ expect(storage.getMessages).toHaveBeenCalledTimes(1);
137
+ });
138
+
139
+ it('should update cache with new member without storage query', async () => {
140
+ const messages: Message[] = [];
141
+ const members = [createTestMember('addr1', 'User1')];
142
+ const storage = createMockStorage(messages, members);
143
+
144
+ const service = new SyncService({ storage });
145
+
146
+ // Initialize cache
147
+ const payload1 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
148
+ expect(payload1.summary.memberCount).toBe(1);
149
+
150
+ // Update cache with new member
151
+ const newMember = createTestMember('addr2', 'User2');
152
+ service.updateCacheWithMember(spaceId, channelId, newMember);
153
+
154
+ // Get payload again - should reflect new member without storage query
155
+ const payload2 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
156
+ expect(payload2.summary.memberCount).toBe(2);
157
+
158
+ // Storage should still only be called once (initial load)
159
+ expect(storage.getSpaceMembers).toHaveBeenCalledTimes(1);
160
+ });
161
+
162
+ it('should invalidate cache and reload from storage', async () => {
163
+ const messages = [createTestMessage('msg1', spaceId, channelId, 'Hello')];
164
+ const members = [createTestMember('addr1', 'User1')];
165
+ const storage = createMockStorage(messages, members);
166
+
167
+ const service = new SyncService({ storage });
168
+
169
+ // Initialize cache
170
+ await service.buildSyncRequest(spaceId, channelId, inboxAddress);
171
+ expect(storage.getMessages).toHaveBeenCalledTimes(1);
172
+
173
+ // Invalidate cache
174
+ service.invalidateCache(spaceId, channelId);
175
+
176
+ // Next call should fetch from storage again
177
+ await service.buildSyncRequest(spaceId, channelId, inboxAddress);
178
+ expect(storage.getMessages).toHaveBeenCalledTimes(2);
179
+ });
180
+ });
181
+
182
+ describe('buildSyncRequest', () => {
183
+ it('should return correct payload structure', async () => {
184
+ const messages = [
185
+ createTestMessage('msg1', spaceId, channelId, 'Hello', 1000),
186
+ createTestMessage('msg2', spaceId, channelId, 'World', 2000),
187
+ ];
188
+ const members = [createTestMember('addr1')];
189
+ const storage = createMockStorage(messages, members);
190
+
191
+ const service = new SyncService({ storage });
192
+ const payload = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
193
+
194
+ expect(payload.type).toBe('sync-request');
195
+ expect(payload.inboxAddress).toBe(inboxAddress);
196
+ expect(payload.expiry).toBeGreaterThan(Date.now());
197
+ expect(payload.summary.messageCount).toBe(2);
198
+ expect(payload.summary.memberCount).toBe(1);
199
+ expect(payload.summary.oldestMessageTimestamp).toBe(1000);
200
+ expect(payload.summary.newestMessageTimestamp).toBe(2000);
201
+ });
202
+
203
+ it('should create a sync session', async () => {
204
+ const storage = createMockStorage([], []);
205
+ const service = new SyncService({ storage });
206
+
207
+ await service.buildSyncRequest(spaceId, channelId, inboxAddress);
208
+
209
+ expect(service.hasActiveSession(spaceId)).toBe(true);
210
+ });
211
+ });
212
+
213
+ describe('buildSyncInfo', () => {
214
+ it('should return null when we have no data', async () => {
215
+ const storage = createMockStorage([], []);
216
+ const service = new SyncService({ storage });
217
+
218
+ const theirSummary: SyncSummary = {
219
+ messageCount: 5,
220
+ memberCount: 3,
221
+ newestMessageTimestamp: 1000,
222
+ oldestMessageTimestamp: 500,
223
+ };
224
+
225
+ const result = await service.buildSyncInfo(spaceId, channelId, inboxAddress, theirSummary);
226
+ expect(result).toBeNull();
227
+ });
228
+
229
+ it('should return null when manifests match', async () => {
230
+ const messages = [createTestMessage('msg1', spaceId, channelId, 'Hello', 1000)];
231
+ const members = [createTestMember('addr1')];
232
+ const storage = createMockStorage(messages, members);
233
+
234
+ const service = new SyncService({ storage });
235
+
236
+ // Build our summary first to get the manifest hash
237
+ const ourPayload = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
238
+
239
+ // Their summary matches ours
240
+ const theirSummary: SyncSummary = {
241
+ messageCount: 1,
242
+ memberCount: 1,
243
+ newestMessageTimestamp: 1000,
244
+ oldestMessageTimestamp: 1000,
245
+ manifestHash: ourPayload.summary.manifestHash,
246
+ };
247
+
248
+ const result = await service.buildSyncInfo(spaceId, channelId, inboxAddress, theirSummary);
249
+ expect(result).toBeNull();
250
+ });
251
+
252
+ it('should return sync-info when we have more messages', async () => {
253
+ const messages = [
254
+ createTestMessage('msg1', spaceId, channelId, 'Hello', 1000),
255
+ createTestMessage('msg2', spaceId, channelId, 'World', 2000),
256
+ ];
257
+ const members = [createTestMember('addr1')];
258
+ const storage = createMockStorage(messages, members);
259
+
260
+ const service = new SyncService({ storage });
261
+
262
+ const theirSummary: SyncSummary = {
263
+ messageCount: 1,
264
+ memberCount: 1,
265
+ newestMessageTimestamp: 1000,
266
+ oldestMessageTimestamp: 1000,
267
+ };
268
+
269
+ const result = await service.buildSyncInfo(spaceId, channelId, inboxAddress, theirSummary);
270
+ expect(result).not.toBeNull();
271
+ expect(result!.type).toBe('sync-info');
272
+ expect(result!.summary.messageCount).toBe(2);
273
+ });
274
+
275
+ it('should return sync-info when we have newer messages', async () => {
276
+ const messages = [createTestMessage('msg1', spaceId, channelId, 'Hello', 2000)];
277
+ const members = [createTestMember('addr1')];
278
+ const storage = createMockStorage(messages, members);
279
+
280
+ const service = new SyncService({ storage });
281
+
282
+ const theirSummary: SyncSummary = {
283
+ messageCount: 1,
284
+ memberCount: 1,
285
+ newestMessageTimestamp: 1000,
286
+ oldestMessageTimestamp: 1000,
287
+ };
288
+
289
+ const result = await service.buildSyncInfo(spaceId, channelId, inboxAddress, theirSummary);
290
+ expect(result).not.toBeNull();
291
+ });
292
+ });
293
+
294
+ describe('buildSyncManifest', () => {
295
+ it('should return correct manifest payload', async () => {
296
+ const messages = [
297
+ createTestMessage('msg1', spaceId, channelId, 'Hello', 1000),
298
+ createTestMessage('msg2', spaceId, channelId, 'World', 2000),
299
+ ];
300
+ const members = [
301
+ createTestMember('addr1', 'User1'),
302
+ createTestMember('addr2', 'User2'),
303
+ ];
304
+ const storage = createMockStorage(messages, members);
305
+
306
+ const service = new SyncService({ storage });
307
+ const peerIds = [1, 2, 3];
308
+
309
+ const payload = await service.buildSyncManifest(spaceId, channelId, peerIds, inboxAddress);
310
+
311
+ expect(payload.type).toBe('sync-manifest');
312
+ expect(payload.inboxAddress).toBe(inboxAddress);
313
+ expect(payload.manifest.messageCount).toBe(2);
314
+ expect(payload.manifest.digests).toHaveLength(2);
315
+ expect(payload.memberDigests).toHaveLength(2);
316
+ expect(payload.peerIds).toEqual(peerIds);
317
+ });
318
+
319
+ it('should use cached data', async () => {
320
+ const messages = [createTestMessage('msg1', spaceId, channelId, 'Hello')];
321
+ const members = [createTestMember('addr1')];
322
+ const storage = createMockStorage(messages, members);
323
+
324
+ const service = new SyncService({ storage });
325
+
326
+ // First call - loads cache
327
+ await service.buildSyncManifest(spaceId, channelId, [], inboxAddress);
328
+ // Second call - should use cache
329
+ await service.buildSyncManifest(spaceId, channelId, [], inboxAddress);
330
+
331
+ expect(storage.getMessages).toHaveBeenCalledTimes(1);
332
+ });
333
+ });
334
+
335
+ describe('buildSyncDelta', () => {
336
+ it('should return empty delta when no differences', async () => {
337
+ const messages = [createTestMessage('msg1', spaceId, channelId, 'Hello', 1000)];
338
+ const members = [createTestMember('addr1')];
339
+ const storage = createMockStorage(messages, members);
340
+
341
+ const service = new SyncService({ storage });
342
+
343
+ // Build our manifest
344
+ const ourManifest = await service.buildSyncManifest(spaceId, channelId, [], inboxAddress);
345
+
346
+ // Their manifest is the same as ours
347
+ const deltas = await service.buildSyncDelta(
348
+ spaceId,
349
+ channelId,
350
+ ourManifest.manifest,
351
+ ourManifest.memberDigests,
352
+ [],
353
+ new Map()
354
+ );
355
+
356
+ // Should have one final delta with no data
357
+ expect(deltas).toHaveLength(1);
358
+ expect(deltas[0].isFinal).toBe(true);
359
+ expect(deltas[0].messageDelta).toBeUndefined();
360
+ expect(deltas[0].memberDelta).toBeUndefined();
361
+ });
362
+
363
+ it('should include messages they are missing', async () => {
364
+ const messages = [
365
+ createTestMessage('msg1', spaceId, channelId, 'Hello', 1000),
366
+ createTestMessage('msg2', spaceId, channelId, 'World', 2000),
367
+ ];
368
+ const members = [createTestMember('addr1')];
369
+ const storage = createMockStorage(messages, members);
370
+
371
+ const service = new SyncService({ storage });
372
+
373
+ // Their manifest only has msg1
374
+ const theirManifest: SyncManifest = createManifest(spaceId, channelId, [messages[0]]);
375
+ const theirMemberDigests = members.map(createMemberDigest);
376
+
377
+ const deltas = await service.buildSyncDelta(
378
+ spaceId,
379
+ channelId,
380
+ theirManifest,
381
+ theirMemberDigests,
382
+ [],
383
+ new Map()
384
+ );
385
+
386
+ // Should include msg2 as new message
387
+ const allNewMessages = deltas.flatMap((d) => d.messageDelta?.newMessages || []);
388
+ expect(allNewMessages).toHaveLength(1);
389
+ expect(allNewMessages[0].messageId).toBe('msg2');
390
+ });
391
+
392
+ it('should include members they are missing', async () => {
393
+ const messages: Message[] = [];
394
+ const members = [
395
+ createTestMember('addr1', 'User1'),
396
+ createTestMember('addr2', 'User2'),
397
+ ];
398
+ const storage = createMockStorage(messages, members);
399
+
400
+ const service = new SyncService({ storage });
401
+
402
+ // They only have addr1
403
+ const theirManifest: SyncManifest = createManifest(spaceId, channelId, []);
404
+ const theirMemberDigests = [createMemberDigest(members[0])];
405
+
406
+ const deltas = await service.buildSyncDelta(
407
+ spaceId,
408
+ channelId,
409
+ theirManifest,
410
+ theirMemberDigests,
411
+ [],
412
+ new Map()
413
+ );
414
+
415
+ // Should include addr2 as new member
416
+ const finalDelta = deltas.find((d) => d.isFinal);
417
+ expect(finalDelta?.memberDelta?.members).toHaveLength(1);
418
+ expect(finalDelta?.memberDelta?.members[0].address).toBe('addr2');
419
+ });
420
+ });
421
+
422
+ describe('buildSyncDelta chunking', () => {
423
+ // Use 1MB chunk size for testing (matching realistic network limits)
424
+ const TEST_CHUNK_SIZE = 1 * 1024 * 1024; // 1MB
425
+ const msgSize = 200000; // 200KB per message
426
+ const numMessagesForChunking = 6; // 6 messages * 200KB = 1.2MB > 1MB
427
+
428
+ it('should chunk large message sets into multiple payloads', async () => {
429
+ // Create messages that will exceed chunk size
430
+ const largeText = 'x'.repeat(msgSize);
431
+
432
+ const messages: Message[] = [];
433
+ for (let i = 0; i < numMessagesForChunking; i++) {
434
+ messages.push(createTestMessage(`msg${i}`, spaceId, channelId, largeText, 1000 + i));
435
+ }
436
+
437
+ const members = [createTestMember('addr1')];
438
+ const storage = createMockStorage(messages, members);
439
+ const service = new SyncService({ storage });
440
+
441
+ // Their manifest is empty - they need all messages
442
+ const theirManifest: SyncManifest = createManifest(spaceId, channelId, []);
443
+ const theirMemberDigests: MemberDigest[] = [createMemberDigest(members[0])];
444
+
445
+ const deltas = await service.buildSyncDelta(
446
+ spaceId,
447
+ channelId,
448
+ theirManifest,
449
+ theirMemberDigests,
450
+ [],
451
+ new Map()
452
+ );
453
+
454
+ // Should have multiple chunks (6 messages at 200KB each = 1.2MB, chunked at 5MB default)
455
+ // With default 5MB chunks, this won't chunk, but verifies the mechanism works
456
+ expect(deltas.length).toBeGreaterThanOrEqual(1);
457
+
458
+ // Only last delta should be final
459
+ const finalDeltas = deltas.filter(d => d.isFinal);
460
+ expect(finalDeltas).toHaveLength(1);
461
+ expect(deltas[deltas.length - 1].isFinal).toBe(true);
462
+
463
+ // All messages should be included across chunks
464
+ const allNewMessages = deltas.flatMap(d => d.messageDelta?.newMessages || []);
465
+ expect(allNewMessages).toHaveLength(numMessagesForChunking);
466
+ });
467
+
468
+ it('should chunk when total size exceeds MAX_CHUNK_SIZE', async () => {
469
+ // Create enough messages to exceed MAX_CHUNK_SIZE (5MB)
470
+ // 1MB per message * 6 = 6MB > 5MB
471
+ const oneMbText = 'x'.repeat(1024 * 1024);
472
+ const numMessages = 6;
473
+
474
+ const messages: Message[] = [];
475
+ for (let i = 0; i < numMessages; i++) {
476
+ messages.push(createTestMessage(`msg${i}`, spaceId, channelId, oneMbText, 1000 + i));
477
+ }
478
+
479
+ const members = [createTestMember('addr1')];
480
+ const storage = createMockStorage(messages, members);
481
+ const service = new SyncService({ storage });
482
+
483
+ const theirManifest: SyncManifest = createManifest(spaceId, channelId, []);
484
+ const theirMemberDigests: MemberDigest[] = [createMemberDigest(members[0])];
485
+
486
+ const deltas = await service.buildSyncDelta(
487
+ spaceId,
488
+ channelId,
489
+ theirManifest,
490
+ theirMemberDigests,
491
+ [],
492
+ new Map()
493
+ );
494
+
495
+ // Should have multiple chunks since 6MB > 5MB limit
496
+ expect(deltas.length).toBeGreaterThan(1);
497
+
498
+ // All messages should be included across chunks
499
+ const allNewMessages = deltas.flatMap(d => d.messageDelta?.newMessages || []);
500
+ expect(allNewMessages).toHaveLength(numMessages);
501
+
502
+ // Only last delta should be final
503
+ expect(deltas[deltas.length - 1].isFinal).toBe(true);
504
+ expect(deltas.filter(d => d.isFinal)).toHaveLength(1);
505
+ });
506
+
507
+ it('should handle single oversized message', async () => {
508
+ // Create a single message larger than MAX_CHUNK_SIZE (5MB)
509
+ const hugeText = 'x'.repeat(MAX_CHUNK_SIZE + 1000);
510
+ const messages = [createTestMessage('msg1', spaceId, channelId, hugeText, 1000)];
511
+ const members = [createTestMember('addr1')];
512
+ const storage = createMockStorage(messages, members);
513
+ const service = new SyncService({ storage });
514
+
515
+ const theirManifest: SyncManifest = createManifest(spaceId, channelId, []);
516
+ const theirMemberDigests: MemberDigest[] = [createMemberDigest(members[0])];
517
+
518
+ const deltas = await service.buildSyncDelta(
519
+ spaceId,
520
+ channelId,
521
+ theirManifest,
522
+ theirMemberDigests,
523
+ [],
524
+ new Map()
525
+ );
526
+
527
+ // Should still work - message sent in its own chunk
528
+ const allNewMessages = deltas.flatMap(d => d.messageDelta?.newMessages || []);
529
+ expect(allNewMessages).toHaveLength(1);
530
+ expect(allNewMessages[0].messageId).toBe('msg1');
531
+ });
532
+
533
+ it('should include reaction delta only in last message chunk', async () => {
534
+ // Create messages that will require chunking (1MB each, 6 total = 6MB > 5MB)
535
+ const oneMbText = 'x'.repeat(1024 * 1024);
536
+ const messages: Message[] = [];
537
+ for (let i = 0; i < 6; i++) {
538
+ const msg = createTestMessage(`msg${i}`, spaceId, channelId, oneMbText, 1000 + i);
539
+ // Add reactions to some messages
540
+ if (i % 2 === 0) {
541
+ msg.reactions = [{
542
+ emojiId: '👍',
543
+ emojiName: 'thumbsup',
544
+ spaceId,
545
+ count: 1,
546
+ memberIds: ['addr1'],
547
+ }];
548
+ }
549
+ messages.push(msg);
550
+ }
551
+
552
+ const members = [createTestMember('addr1')];
553
+ const storage = createMockStorage(messages, members);
554
+ const service = new SyncService({ storage });
555
+
556
+ const theirManifest: SyncManifest = createManifest(spaceId, channelId, []);
557
+ const theirMemberDigests: MemberDigest[] = [createMemberDigest(members[0])];
558
+
559
+ const deltas = await service.buildSyncDelta(
560
+ spaceId,
561
+ channelId,
562
+ theirManifest,
563
+ theirMemberDigests,
564
+ [],
565
+ new Map()
566
+ );
567
+
568
+ // Reaction delta should only be in the last message chunk (before member delta)
569
+ const deltasWithReactions = deltas.filter(d => d.reactionDelta && d.reactionDelta.added.length > 0);
570
+ expect(deltasWithReactions.length).toBeLessThanOrEqual(1);
571
+ });
572
+
573
+ it('should put member delta in final chunk', async () => {
574
+ // Create messages that will require chunking (1MB each)
575
+ const oneMbText = 'x'.repeat(1024 * 1024);
576
+ const messages: Message[] = [];
577
+ for (let i = 0; i < 6; i++) {
578
+ messages.push(createTestMessage(`msg${i}`, spaceId, channelId, oneMbText, 1000 + i));
579
+ }
580
+
581
+ const members = [
582
+ createTestMember('addr1', 'User1'),
583
+ createTestMember('addr2', 'User2'),
584
+ ];
585
+ const storage = createMockStorage(messages, members);
586
+ const service = new SyncService({ storage });
587
+
588
+ // They have no members
589
+ const theirManifest: SyncManifest = createManifest(spaceId, channelId, []);
590
+ const theirMemberDigests: MemberDigest[] = [];
591
+
592
+ const deltas = await service.buildSyncDelta(
593
+ spaceId,
594
+ channelId,
595
+ theirManifest,
596
+ theirMemberDigests,
597
+ [],
598
+ new Map()
599
+ );
600
+
601
+ // Member delta should be in the final payload
602
+ const finalDelta = deltas.find(d => d.isFinal);
603
+ expect(finalDelta).toBeDefined();
604
+ expect(finalDelta?.memberDelta?.members).toHaveLength(2);
605
+ });
606
+ });
607
+
608
+ describe('O(1) cache updates', () => {
609
+ it('should not recompute manifest hash when updating existing message', async () => {
610
+ const messages = [createTestMessage('msg1', spaceId, channelId, 'Hello', 1000)];
611
+ const members = [createTestMember('addr1')];
612
+ const storage = createMockStorage(messages, members);
613
+
614
+ const service = new SyncService({ storage });
615
+
616
+ // Initialize cache and get initial hash
617
+ const payload1 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
618
+ const hash1 = payload1.summary.manifestHash;
619
+
620
+ // Update existing message (not adding a new one)
621
+ const updatedMessage = createTestMessage('msg1', spaceId, channelId, 'Updated text', 1000);
622
+ service.updateCacheWithMessage(spaceId, channelId, updatedMessage);
623
+
624
+ // Hash should remain the same (message set didn't change, only content)
625
+ const payload2 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
626
+ const hash2 = payload2.summary.manifestHash;
627
+
628
+ // Manifest hash is based on message IDs, not content, so should be same
629
+ expect(hash1).toBe(hash2);
630
+ });
631
+
632
+ it('should lazily recompute manifest hash only when needed after adding message', async () => {
633
+ const messages = [createTestMessage('msg1', spaceId, channelId, 'Hello', 1000)];
634
+ const members = [createTestMember('addr1')];
635
+ const storage = createMockStorage(messages, members);
636
+
637
+ const service = new SyncService({ storage });
638
+
639
+ // Initialize cache
640
+ await service.buildSyncRequest(spaceId, channelId, inboxAddress);
641
+
642
+ // Add multiple messages without accessing the hash
643
+ for (let i = 2; i <= 10; i++) {
644
+ service.updateCacheWithMessage(
645
+ spaceId,
646
+ channelId,
647
+ createTestMessage(`msg${i}`, spaceId, channelId, `Message ${i}`, 1000 + i)
648
+ );
649
+ }
650
+
651
+ // Now request summary - hash should be computed once
652
+ const payload = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
653
+ expect(payload.summary.messageCount).toBe(10);
654
+ expect(payload.summary.manifestHash).toBeDefined();
655
+ });
656
+
657
+ it('should update timestamps in O(1) when adding newer message', async () => {
658
+ const messages = [createTestMessage('msg1', spaceId, channelId, 'Hello', 1000)];
659
+ const members = [createTestMember('addr1')];
660
+ const storage = createMockStorage(messages, members);
661
+
662
+ const service = new SyncService({ storage });
663
+
664
+ // Initialize cache
665
+ const payload1 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
666
+ expect(payload1.summary.newestMessageTimestamp).toBe(1000);
667
+ expect(payload1.summary.oldestMessageTimestamp).toBe(1000);
668
+
669
+ // Add newer message
670
+ service.updateCacheWithMessage(
671
+ spaceId,
672
+ channelId,
673
+ createTestMessage('msg2', spaceId, channelId, 'Newer', 2000)
674
+ );
675
+
676
+ const payload2 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
677
+ expect(payload2.summary.newestMessageTimestamp).toBe(2000);
678
+ expect(payload2.summary.oldestMessageTimestamp).toBe(1000);
679
+
680
+ // Add older message
681
+ service.updateCacheWithMessage(
682
+ spaceId,
683
+ channelId,
684
+ createTestMessage('msg0', spaceId, channelId, 'Older', 500)
685
+ );
686
+
687
+ const payload3 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
688
+ expect(payload3.summary.newestMessageTimestamp).toBe(2000);
689
+ expect(payload3.summary.oldestMessageTimestamp).toBe(500);
690
+ });
691
+
692
+ it('should update member count in O(1)', async () => {
693
+ const messages: Message[] = [];
694
+ const members = [createTestMember('addr1')];
695
+ const storage = createMockStorage(messages, members);
696
+
697
+ const service = new SyncService({ storage });
698
+
699
+ // Initialize cache
700
+ const payload1 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
701
+ expect(payload1.summary.memberCount).toBe(1);
702
+
703
+ // Add members without storage query
704
+ service.updateCacheWithMember(spaceId, channelId, createTestMember('addr2'));
705
+ service.updateCacheWithMember(spaceId, channelId, createTestMember('addr3'));
706
+
707
+ const payload2 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
708
+ expect(payload2.summary.memberCount).toBe(3);
709
+
710
+ // Storage should only be called once (initial load)
711
+ expect(storage.getSpaceMembers).toHaveBeenCalledTimes(1);
712
+ });
713
+
714
+ it('should restore original hash after adding then removing a message (XOR property)', async () => {
715
+ const messages = [createTestMessage('msg1', spaceId, channelId, 'Hello', 1000)];
716
+ const members = [createTestMember('addr1')];
717
+ const storage = createMockStorage(messages, members);
718
+
719
+ const service = new SyncService({ storage });
720
+
721
+ // Get initial hash
722
+ const payload1 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
723
+ const originalHash = payload1.summary.manifestHash;
724
+
725
+ // Add a message
726
+ service.updateCacheWithMessage(
727
+ spaceId,
728
+ channelId,
729
+ createTestMessage('msg2', spaceId, channelId, 'World', 2000)
730
+ );
731
+
732
+ // Hash should be different
733
+ const payload2 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
734
+ expect(payload2.summary.manifestHash).not.toBe(originalHash);
735
+
736
+ // Remove the message we added
737
+ service.removeCacheMessage(spaceId, channelId, 'msg2');
738
+
739
+ // Hash should be back to original (XOR is its own inverse)
740
+ const payload3 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
741
+ expect(payload3.summary.manifestHash).toBe(originalHash);
742
+ });
743
+
744
+ it('should produce same hash regardless of message add order (XOR commutativity)', async () => {
745
+ const members = [createTestMember('addr1')];
746
+
747
+ // First service: add msg1, msg2, msg3 in order
748
+ const storage1 = createMockStorage([], members);
749
+ const service1 = new SyncService({ storage: storage1 });
750
+ await service1.buildSyncRequest(spaceId, channelId, inboxAddress);
751
+ service1.updateCacheWithMessage(spaceId, channelId, createTestMessage('msg1', spaceId, channelId, 'A', 1000));
752
+ service1.updateCacheWithMessage(spaceId, channelId, createTestMessage('msg2', spaceId, channelId, 'B', 2000));
753
+ service1.updateCacheWithMessage(spaceId, channelId, createTestMessage('msg3', spaceId, channelId, 'C', 3000));
754
+ const hash1 = (await service1.buildSyncRequest(spaceId, channelId, inboxAddress)).summary.manifestHash;
755
+
756
+ // Second service: add msg3, msg1, msg2 in different order
757
+ const storage2 = createMockStorage([], members);
758
+ const service2 = new SyncService({ storage: storage2 });
759
+ await service2.buildSyncRequest(spaceId, channelId, inboxAddress);
760
+ service2.updateCacheWithMessage(spaceId, channelId, createTestMessage('msg3', spaceId, channelId, 'C', 3000));
761
+ service2.updateCacheWithMessage(spaceId, channelId, createTestMessage('msg1', spaceId, channelId, 'A', 1000));
762
+ service2.updateCacheWithMessage(spaceId, channelId, createTestMessage('msg2', spaceId, channelId, 'B', 2000));
763
+ const hash2 = (await service2.buildSyncRequest(spaceId, channelId, inboxAddress)).summary.manifestHash;
764
+
765
+ // Hashes should be identical (XOR is commutative)
766
+ expect(hash1).toBe(hash2);
767
+ });
768
+ });
769
+
770
+ describe('Cache consistency after updates', () => {
771
+ it('should maintain consistent manifest hash after message updates', async () => {
772
+ const messages = [createTestMessage('msg1', spaceId, channelId, 'Hello', 1000)];
773
+ const members = [createTestMember('addr1')];
774
+ const storage = createMockStorage(messages, members);
775
+
776
+ const service = new SyncService({ storage });
777
+
778
+ // Get initial hash
779
+ const payload1 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
780
+ const hash1 = payload1.summary.manifestHash;
781
+
782
+ // Add a message
783
+ const newMessage = createTestMessage('msg2', spaceId, channelId, 'World', 2000);
784
+ service.updateCacheWithMessage(spaceId, channelId, newMessage);
785
+
786
+ // Get new hash
787
+ const payload2 = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
788
+ const hash2 = payload2.summary.manifestHash;
789
+
790
+ // Hashes should be different
791
+ expect(hash1).not.toBe(hash2);
792
+
793
+ // Verify the new manifest includes both messages
794
+ const manifest = await service.buildSyncManifest(spaceId, channelId, [], inboxAddress);
795
+ expect(manifest.manifest.digests).toHaveLength(2);
796
+ });
797
+
798
+ it('should correctly update existing message in cache', async () => {
799
+ const messages = [createTestMessage('msg1', spaceId, channelId, 'Hello', 1000)];
800
+ const members = [createTestMember('addr1')];
801
+ const storage = createMockStorage(messages, members);
802
+
803
+ const service = new SyncService({ storage });
804
+
805
+ // Initialize cache
806
+ await service.buildSyncRequest(spaceId, channelId, inboxAddress);
807
+
808
+ // Update existing message
809
+ const updatedMessage = createTestMessage('msg1', spaceId, channelId, 'Updated', 1000);
810
+ updatedMessage.modifiedDate = 2000;
811
+ service.updateCacheWithMessage(spaceId, channelId, updatedMessage);
812
+
813
+ // Message count should still be 1
814
+ const payload = await service.buildSyncRequest(spaceId, channelId, inboxAddress);
815
+ expect(payload.summary.messageCount).toBe(1);
816
+
817
+ // But the content hash should have changed
818
+ const manifest = await service.buildSyncManifest(spaceId, channelId, [], inboxAddress);
819
+ expect(manifest.manifest.digests[0].modifiedDate).toBe(2000);
820
+ });
821
+ });
822
+ });