@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,947 @@
1
+ /**
2
+ * SyncService
3
+ *
4
+ * Platform-agnostic sync orchestration logic.
5
+ * Handles sync protocol without encryption (that's platform-specific).
6
+ *
7
+ * Usage:
8
+ * 1. Platform creates SyncService with storage adapter
9
+ * 2. Platform calls build* methods to create payloads
10
+ * 3. Platform handles encryption and transmission
11
+ * 4. Platform calls apply* methods to process received data
12
+ */
13
+
14
+ import type { StorageAdapter } from '../storage';
15
+ import type { Message, SpaceMember } from '../types';
16
+ import { logger } from '../utils/logger';
17
+ import type {
18
+ SyncManifest,
19
+ MessageDelta,
20
+ ReactionDelta,
21
+ MemberDigest,
22
+ MemberDelta,
23
+ PeerEntry,
24
+ PeerMapDelta,
25
+ SyncRequestPayload,
26
+ SyncInfoPayload,
27
+ SyncInitiatePayload,
28
+ SyncManifestPayload,
29
+ SyncDeltaPayload,
30
+ SyncSession,
31
+ SyncCandidate,
32
+ SyncSummary,
33
+ DeletedMessageTombstone,
34
+ } from './types';
35
+ import { sha256 } from '@noble/hashes/sha2';
36
+ import { bytesToHex } from '../utils/encoding';
37
+ import {
38
+ createManifest,
39
+ createMessageDigest,
40
+ createMemberDigest,
41
+ createReactionDigest,
42
+ computeMessageDiff,
43
+ computeMemberDiff,
44
+ computePeerDiff,
45
+ buildMessageDelta,
46
+ buildReactionDelta,
47
+ buildMemberDelta,
48
+ chunkMessages,
49
+ DEFAULT_SYNC_EXPIRY_MS,
50
+ AGGRESSIVE_SYNC_TIMEOUT_MS,
51
+ } from './utils';
52
+
53
+ // ============ Configuration ============
54
+
55
+ export interface SyncServiceConfig {
56
+ storage: StorageAdapter;
57
+ /** Maximum messages to include in sync (default: 1000) */
58
+ maxMessages?: number;
59
+ /** Sync request expiry in ms (default: 30000) */
60
+ requestExpiry?: number;
61
+ /** Callback when sync should be initiated */
62
+ onInitiateSync?: (spaceId: string, target: string) => void;
63
+ /** Cache TTL in ms (default: 5000) */
64
+ cacheTtl?: number;
65
+ }
66
+
67
+ // ============ Cache Types ============
68
+
69
+ interface SyncPayloadCache {
70
+ /** Space and channel IDs */
71
+ spaceId: string;
72
+ channelId: string;
73
+ /** Message map for delta building - O(1) lookup */
74
+ messageMap: Map<string, Message>;
75
+ /** Member map for delta building - O(1) lookup */
76
+ memberMap: Map<string, SpaceMember>;
77
+ /** Message digest map - O(1) lookup/update */
78
+ digestMap: Map<string, MessageDigest>;
79
+ /** Member digest map - O(1) lookup/update */
80
+ memberDigestMap: Map<string, MemberDigest>;
81
+ /** Tracked timestamps for O(1) summary updates */
82
+ oldestTimestamp: number;
83
+ newestTimestamp: number;
84
+ /** XOR-based manifest hash bytes for O(1) incremental updates */
85
+ manifestHashBytes: Uint8Array;
86
+ }
87
+
88
+ // ============ SyncService Class ============
89
+
90
+ export class SyncService {
91
+ private storage: StorageAdapter;
92
+ private maxMessages: number;
93
+ private requestExpiry: number;
94
+ private onInitiateSync?: (spaceId: string, target: string) => void;
95
+
96
+ /** Active sync sessions by spaceId */
97
+ private sessions: Map<string, SyncSession> = new Map();
98
+
99
+ /** Deleted message tombstones (caller must persist these) */
100
+ private tombstones: DeletedMessageTombstone[] = [];
101
+
102
+ /** Pre-computed sync payload cache per space:channel - always ready to use */
103
+ private payloadCache: Map<string, SyncPayloadCache> = new Map();
104
+
105
+ constructor(config: SyncServiceConfig) {
106
+ this.storage = config.storage;
107
+ this.maxMessages = config.maxMessages ?? 1000;
108
+ this.requestExpiry = config.requestExpiry ?? DEFAULT_SYNC_EXPIRY_MS;
109
+ this.onInitiateSync = config.onInitiateSync;
110
+ }
111
+
112
+ // ============ Payload Cache Management ============
113
+
114
+ /**
115
+ * Get cache key for space/channel
116
+ */
117
+ private getCacheKey(spaceId: string, channelId: string): string {
118
+ return `${spaceId}:${channelId}`;
119
+ }
120
+
121
+ /**
122
+ * Get or initialize the payload cache for a space/channel.
123
+ * If not cached, loads from storage and builds the payload once.
124
+ */
125
+ private async getPayloadCache(spaceId: string, channelId: string): Promise<SyncPayloadCache> {
126
+ const key = this.getCacheKey(spaceId, channelId);
127
+ const cached = this.payloadCache.get(key);
128
+
129
+ if (cached) {
130
+ logger.log(`[SyncService] Using cached payload for ${spaceId.substring(0, 12)}:${channelId.substring(0, 12)}`);
131
+ return cached;
132
+ }
133
+
134
+ // Initial load from storage - this only happens once per space/channel
135
+ logger.log(`[SyncService] Building initial payload cache for ${spaceId.substring(0, 12)}:${channelId.substring(0, 12)}`);
136
+ const messages = await this.getChannelMessages(spaceId, channelId);
137
+ const members = await this.storage.getSpaceMembers(spaceId);
138
+
139
+ const payload = this.buildPayloadCache(spaceId, channelId, messages, members);
140
+ this.payloadCache.set(key, payload);
141
+
142
+ return payload;
143
+ }
144
+
145
+ /**
146
+ * Hash a message ID to bytes for XOR-based manifest hash
147
+ */
148
+ private hashMessageId(messageId: string): Uint8Array {
149
+ return sha256(new TextEncoder().encode(messageId));
150
+ }
151
+
152
+ /**
153
+ * XOR hash bytes into the accumulator - O(1)
154
+ */
155
+ private xorIntoHash(accumulator: Uint8Array, hash: Uint8Array): void {
156
+ for (let i = 0; i < 32; i++) {
157
+ accumulator[i] ^= hash[i];
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Build the payload cache from messages and members - O(n) initial build
163
+ */
164
+ private buildPayloadCache(
165
+ spaceId: string,
166
+ channelId: string,
167
+ messages: Message[],
168
+ members: SpaceMember[]
169
+ ): SyncPayloadCache {
170
+ const messageMap = new Map(messages.map(m => [m.messageId, m]));
171
+ const memberMap = new Map(members.map(m => [m.address, m]));
172
+ const digestMap = new Map(messages.map(m => [m.messageId, createMessageDigest(m)]));
173
+ const memberDigestMap = new Map(members.map(m => [m.address, createMemberDigest(m)]));
174
+
175
+ // Compute timestamps
176
+ let oldestTimestamp = Infinity;
177
+ let newestTimestamp = 0;
178
+ for (const msg of messages) {
179
+ if (msg.createdDate < oldestTimestamp) oldestTimestamp = msg.createdDate;
180
+ if (msg.createdDate > newestTimestamp) newestTimestamp = msg.createdDate;
181
+ }
182
+ if (messages.length === 0) {
183
+ oldestTimestamp = 0;
184
+ }
185
+
186
+ // Compute XOR-based manifest hash - O(n) initial build
187
+ const manifestHashBytes = new Uint8Array(32);
188
+ for (const msg of messages) {
189
+ this.xorIntoHash(manifestHashBytes, this.hashMessageId(msg.messageId));
190
+ }
191
+
192
+ return {
193
+ spaceId,
194
+ channelId,
195
+ messageMap,
196
+ memberMap,
197
+ digestMap,
198
+ memberDigestMap,
199
+ oldestTimestamp,
200
+ newestTimestamp,
201
+ manifestHashBytes,
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Get manifest hash as hex string - O(1)
207
+ */
208
+ private getManifestHash(cache: SyncPayloadCache): string {
209
+ return bytesToHex(cache.manifestHashBytes);
210
+ }
211
+
212
+ /**
213
+ * Get the manifest from cache - builds it on demand
214
+ */
215
+ private getManifest(cache: SyncPayloadCache): SyncManifest {
216
+ const digests = [...cache.digestMap.values()].sort((a, b) => a.createdDate - b.createdDate);
217
+
218
+ // Collect reaction digests
219
+ const reactionDigests: ReturnType<typeof createReactionDigest> = [];
220
+ for (const msg of cache.messageMap.values()) {
221
+ if (msg.reactions && msg.reactions.length > 0) {
222
+ reactionDigests.push(...createReactionDigest(msg.messageId, msg.reactions));
223
+ }
224
+ }
225
+
226
+ return {
227
+ spaceId: cache.spaceId,
228
+ channelId: cache.channelId,
229
+ messageCount: cache.digestMap.size,
230
+ oldestTimestamp: cache.oldestTimestamp,
231
+ newestTimestamp: cache.newestTimestamp,
232
+ digests,
233
+ reactionDigests,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Get the summary from cache - O(1)
239
+ */
240
+ private getSummary(cache: SyncPayloadCache): SyncSummary {
241
+ return {
242
+ memberCount: cache.memberDigestMap.size,
243
+ messageCount: cache.digestMap.size,
244
+ newestMessageTimestamp: cache.newestTimestamp,
245
+ oldestMessageTimestamp: cache.oldestTimestamp,
246
+ manifestHash: this.getManifestHash(cache),
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Get member digests from cache - O(m)
252
+ */
253
+ private getMemberDigests(cache: SyncPayloadCache): MemberDigest[] {
254
+ return [...cache.memberDigestMap.values()];
255
+ }
256
+
257
+ /**
258
+ * Invalidate cache for a space/channel (forces reload from storage on next access)
259
+ */
260
+ invalidateCache(spaceId: string, channelId?: string): void {
261
+ if (channelId) {
262
+ const key = this.getCacheKey(spaceId, channelId);
263
+ this.payloadCache.delete(key);
264
+ logger.log(`[SyncService] Invalidated cache for ${spaceId.substring(0, 12)}:${channelId.substring(0, 12)}`);
265
+ } else {
266
+ // Invalidate all channels for this space
267
+ for (const key of this.payloadCache.keys()) {
268
+ if (key.startsWith(`${spaceId}:`)) {
269
+ this.payloadCache.delete(key);
270
+ }
271
+ }
272
+ logger.log(`[SyncService] Invalidated all caches for space ${spaceId.substring(0, 12)}`);
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Update cache with a new/updated message - O(1) incremental update
278
+ */
279
+ updateCacheWithMessage(spaceId: string, channelId: string, message: Message): void {
280
+ const key = this.getCacheKey(spaceId, channelId);
281
+ const cached = this.payloadCache.get(key);
282
+
283
+ if (cached) {
284
+ const isNew = !cached.messageMap.has(message.messageId);
285
+
286
+ // O(1) - update maps
287
+ cached.messageMap.set(message.messageId, message);
288
+ cached.digestMap.set(message.messageId, createMessageDigest(message));
289
+
290
+ // O(1) - update timestamps if needed
291
+ if (message.createdDate < cached.oldestTimestamp) {
292
+ cached.oldestTimestamp = message.createdDate;
293
+ }
294
+ if (message.createdDate > cached.newestTimestamp) {
295
+ cached.newestTimestamp = message.createdDate;
296
+ }
297
+
298
+ // O(1) - XOR in the new message ID hash (only for new messages)
299
+ if (isNew) {
300
+ this.xorIntoHash(cached.manifestHashBytes, this.hashMessageId(message.messageId));
301
+ }
302
+
303
+ logger.log(`[SyncService] Updated cache with message ${message.messageId.substring(0, 12)} (O(1))`);
304
+ }
305
+ // If no cache exists, next getPayloadCache call will load from storage
306
+ }
307
+
308
+ /**
309
+ * Remove a message from cache - O(1) for removal, but may need O(n) for timestamp recalc
310
+ */
311
+ removeCacheMessage(spaceId: string, channelId: string, messageId: string): void {
312
+ const key = this.getCacheKey(spaceId, channelId);
313
+ const cached = this.payloadCache.get(key);
314
+
315
+ if (cached) {
316
+ const message = cached.messageMap.get(messageId);
317
+ if (!message) return; // Message not in cache
318
+
319
+ cached.messageMap.delete(messageId);
320
+ cached.digestMap.delete(messageId);
321
+
322
+ // O(1) - XOR out the removed message ID hash (XOR is its own inverse)
323
+ this.xorIntoHash(cached.manifestHashBytes, this.hashMessageId(messageId));
324
+
325
+ // Check if we need to recalculate timestamps (only if removed message was at boundary)
326
+ if (message.createdDate === cached.oldestTimestamp || message.createdDate === cached.newestTimestamp) {
327
+ // O(n) recalculation needed - but this is rare (only when deleting oldest/newest)
328
+ this.recalculateTimestamps(cached);
329
+ }
330
+
331
+ logger.log(`[SyncService] Removed message ${messageId.substring(0, 12)} from cache`);
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Recalculate timestamps from scratch - O(n), only called when necessary
337
+ */
338
+ private recalculateTimestamps(cache: SyncPayloadCache): void {
339
+ let oldestTimestamp = Infinity;
340
+ let newestTimestamp = 0;
341
+
342
+ for (const msg of cache.messageMap.values()) {
343
+ if (msg.createdDate < oldestTimestamp) oldestTimestamp = msg.createdDate;
344
+ if (msg.createdDate > newestTimestamp) newestTimestamp = msg.createdDate;
345
+ }
346
+
347
+ cache.oldestTimestamp = cache.messageMap.size === 0 ? 0 : oldestTimestamp;
348
+ cache.newestTimestamp = newestTimestamp;
349
+ }
350
+
351
+ /**
352
+ * Update cache with a new/updated member - O(1) incremental update
353
+ */
354
+ updateCacheWithMember(spaceId: string, channelId: string, member: SpaceMember): void {
355
+ const key = this.getCacheKey(spaceId, channelId);
356
+ const cached = this.payloadCache.get(key);
357
+
358
+ if (cached) {
359
+ // O(1) - update maps
360
+ cached.memberMap.set(member.address, member);
361
+ cached.memberDigestMap.set(member.address, createMemberDigest(member));
362
+
363
+ logger.log(`[SyncService] Updated cache with member ${member.address.substring(0, 12)} (O(1))`);
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Check if cache exists for a space/channel
369
+ */
370
+ hasCachedPayload(spaceId: string, channelId: string): boolean {
371
+ return this.payloadCache.has(this.getCacheKey(spaceId, channelId));
372
+ }
373
+
374
+ // ============ Session Management ============
375
+
376
+ /**
377
+ * Check if a sync session is active for a space
378
+ */
379
+ hasActiveSession(spaceId: string): boolean {
380
+ const session = this.sessions.get(spaceId);
381
+ if (!session) return false;
382
+ if (Date.now() > session.expiry) {
383
+ this.sessions.delete(spaceId);
384
+ return false;
385
+ }
386
+ return true;
387
+ }
388
+
389
+ /**
390
+ * Check if sync is in progress for a space
391
+ */
392
+ isSyncInProgress(spaceId: string): boolean {
393
+ const session = this.sessions.get(spaceId);
394
+ return session?.inProgress ?? false;
395
+ }
396
+
397
+ /**
398
+ * Mark sync as in progress
399
+ */
400
+ setSyncInProgress(spaceId: string, inProgress: boolean): void {
401
+ const session = this.sessions.get(spaceId);
402
+ if (session) {
403
+ session.inProgress = inProgress;
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Set the sync target (who we're syncing with)
409
+ */
410
+ setSyncTarget(spaceId: string, targetInbox: string): void {
411
+ const session = this.sessions.get(spaceId);
412
+ if (session) {
413
+ session.syncTarget = targetInbox;
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Get the sync target for a space
419
+ */
420
+ getSyncTarget(spaceId: string): string | undefined {
421
+ const session = this.sessions.get(spaceId);
422
+ return session?.syncTarget;
423
+ }
424
+
425
+ // ============ Step 1: Sync Request ============
426
+
427
+ /**
428
+ * Build sync-request payload to broadcast via hub
429
+ */
430
+ async buildSyncRequest(
431
+ spaceId: string,
432
+ channelId: string,
433
+ inboxAddress: string
434
+ ): Promise<SyncRequestPayload> {
435
+ const cache = await this.getPayloadCache(spaceId, channelId);
436
+ const expiry = Date.now() + this.requestExpiry;
437
+
438
+ // Create session to track candidates
439
+ this.sessions.set(spaceId, {
440
+ spaceId,
441
+ channelId,
442
+ expiry,
443
+ candidates: [],
444
+ inProgress: false,
445
+ });
446
+
447
+ return {
448
+ type: 'sync-request',
449
+ inboxAddress,
450
+ expiry,
451
+ summary: this.getSummary(cache),
452
+ };
453
+ }
454
+
455
+ /**
456
+ * Schedule sync initiation after timeout
457
+ */
458
+ scheduleSyncInitiation(
459
+ spaceId: string,
460
+ callback: () => void,
461
+ timeoutMs: number = this.requestExpiry
462
+ ): void {
463
+ const session = this.sessions.get(spaceId);
464
+ if (!session) return;
465
+
466
+ // Clear existing timeout
467
+ if (session.timeout) {
468
+ clearTimeout(session.timeout);
469
+ }
470
+
471
+ session.timeout = setTimeout(() => {
472
+ callback();
473
+ }, timeoutMs);
474
+ }
475
+
476
+ // ============ Step 2: Sync Info ============
477
+
478
+ /**
479
+ * Build sync-info response if we have useful data.
480
+ * Returns null if we have nothing to offer or are already in sync.
481
+ */
482
+ async buildSyncInfo(
483
+ spaceId: string,
484
+ channelId: string,
485
+ inboxAddress: string,
486
+ theirSummary: SyncSummary
487
+ ): Promise<SyncInfoPayload | null> {
488
+ logger.log(`[SyncService] buildSyncInfo called for space=${spaceId.substring(0, 12)}, channel=${channelId.substring(0, 12)}`);
489
+ const cache = await this.getPayloadCache(spaceId, channelId);
490
+ const ourSummary = this.getSummary(cache);
491
+
492
+ logger.log(`[SyncService] buildSyncInfo: our data - ${cache.messageMap.size} messages, ${cache.memberMap.size} members`);
493
+ logger.log(`[SyncService] buildSyncInfo: their summary:`, theirSummary);
494
+
495
+ // Nothing to offer
496
+ if (cache.messageMap.size === 0 && cache.memberMap.size === 0) {
497
+ logger.log(`[SyncService] buildSyncInfo: returning null - we have no data`);
498
+ return null;
499
+ }
500
+
501
+ logger.log(`[SyncService] buildSyncInfo: our summary:`, ourSummary);
502
+
503
+ // Quick check: if manifest hashes match and member counts match, likely in sync
504
+ if (
505
+ ourSummary.manifestHash === theirSummary.manifestHash &&
506
+ ourSummary.memberCount === theirSummary.memberCount
507
+ ) {
508
+ logger.log(`[SyncService] buildSyncInfo: returning null - hashes and member counts match`);
509
+ return null;
510
+ }
511
+
512
+ // Check if we have anything they don't
513
+ const hasMoreMessages = ourSummary.messageCount > theirSummary.messageCount;
514
+ const hasMoreMembers = ourSummary.memberCount > theirSummary.memberCount;
515
+ const hasNewerMessages = ourSummary.newestMessageTimestamp > theirSummary.newestMessageTimestamp;
516
+ const hasOlderMessages = ourSummary.oldestMessageTimestamp < theirSummary.oldestMessageTimestamp;
517
+ // If hashes differ, we likely have different messages even if counts are equal
518
+ const hasDifferentMessages = ourSummary.manifestHash !== theirSummary.manifestHash;
519
+
520
+ logger.log(`[SyncService] buildSyncInfo: comparison - hasMoreMessages=${hasMoreMessages}, hasMoreMembers=${hasMoreMembers}, hasNewerMessages=${hasNewerMessages}, hasOlderMessages=${hasOlderMessages}, hasDifferentMessages=${hasDifferentMessages}`);
521
+
522
+ if (!hasMoreMessages && !hasMoreMembers && !hasNewerMessages && !hasOlderMessages && !hasDifferentMessages) {
523
+ // They have same or more data and same messages
524
+ logger.log(`[SyncService] buildSyncInfo: returning null - they have same or more data`);
525
+ return null;
526
+ }
527
+
528
+ logger.log(`[SyncService] buildSyncInfo: returning sync-info response - we have data they don't`);
529
+ return {
530
+ type: 'sync-info',
531
+ inboxAddress,
532
+ summary: ourSummary,
533
+ };
534
+ }
535
+
536
+ /**
537
+ * Add candidate from sync-info response
538
+ */
539
+ addCandidate(spaceId: string, candidate: SyncCandidate): void {
540
+ logger.log(`[SyncService] addCandidate called for space ${spaceId.substring(0, 12)}, candidate inbox: ${candidate.inboxAddress?.substring(0, 12)}`);
541
+ const session = this.sessions.get(spaceId);
542
+ if (!session) {
543
+ logger.log(`[SyncService] addCandidate: No session found for space`);
544
+ return;
545
+ }
546
+ if (Date.now() > session.expiry) {
547
+ logger.log(`[SyncService] addCandidate: Session expired`);
548
+ return;
549
+ }
550
+
551
+ session.candidates.push(candidate);
552
+ logger.log(`[SyncService] addCandidate: Now have ${session.candidates.length} candidates`);
553
+
554
+ // Use aggressive timeout after first candidate
555
+ if (session.candidates.length === 1 && this.onInitiateSync) {
556
+ logger.log(`[SyncService] addCandidate: Scheduling sync initiation in ${AGGRESSIVE_SYNC_TIMEOUT_MS}ms`);
557
+ this.scheduleSyncInitiation(
558
+ spaceId,
559
+ () => {
560
+ const best = this.selectBestCandidate(spaceId);
561
+ logger.log(`[SyncService] addCandidate: Timeout triggered, best candidate: ${best?.inboxAddress?.substring(0, 12) || 'none'}`);
562
+ if (best?.inboxAddress) {
563
+ this.onInitiateSync!(spaceId, best.inboxAddress);
564
+ } else {
565
+ logger.log(`[SyncService] addCandidate: No valid candidate to sync with`);
566
+ }
567
+ },
568
+ AGGRESSIVE_SYNC_TIMEOUT_MS
569
+ );
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Select best candidate based on data availability
575
+ */
576
+ selectBestCandidate(spaceId: string): SyncCandidate | null {
577
+ const session = this.sessions.get(spaceId);
578
+ if (!session || session.candidates.length === 0) return null;
579
+
580
+ // Sort by message count (descending), then member count
581
+ const sorted = [...session.candidates].sort((a, b) => {
582
+ const msgDiff = b.summary.messageCount - a.summary.messageCount;
583
+ if (msgDiff !== 0) return msgDiff;
584
+ return b.summary.memberCount - a.summary.memberCount;
585
+ });
586
+
587
+ return sorted[0];
588
+ }
589
+
590
+ // ============ Step 3: Sync Initiate ============
591
+
592
+ /**
593
+ * Build sync-initiate payload for selected peer
594
+ */
595
+ async buildSyncInitiate(
596
+ spaceId: string,
597
+ channelId: string,
598
+ inboxAddress: string,
599
+ peerIds: number[]
600
+ ): Promise<{ target: string; payload: SyncInitiatePayload } | null> {
601
+ const candidate = this.selectBestCandidate(spaceId);
602
+ if (!candidate) {
603
+ this.sessions.delete(spaceId);
604
+ return null;
605
+ }
606
+
607
+ const cache = await this.getPayloadCache(spaceId, channelId);
608
+
609
+ // Mark sync in progress and store target
610
+ this.setSyncInProgress(spaceId, true);
611
+ this.setSyncTarget(spaceId, candidate.inboxAddress);
612
+
613
+ return {
614
+ target: candidate.inboxAddress,
615
+ payload: {
616
+ type: 'sync-initiate',
617
+ inboxAddress,
618
+ manifest: this.getManifest(cache),
619
+ memberDigests: this.getMemberDigests(cache),
620
+ peerIds,
621
+ },
622
+ };
623
+ }
624
+
625
+ // ============ Step 4: Sync Manifest ============
626
+
627
+ /**
628
+ * Build sync-manifest response to sync-initiate
629
+ */
630
+ async buildSyncManifest(
631
+ spaceId: string,
632
+ channelId: string,
633
+ peerIds: number[],
634
+ inboxAddress: string
635
+ ): Promise<SyncManifestPayload> {
636
+ const cache = await this.getPayloadCache(spaceId, channelId);
637
+
638
+ return {
639
+ type: 'sync-manifest',
640
+ inboxAddress,
641
+ manifest: this.getManifest(cache),
642
+ memberDigests: this.getMemberDigests(cache),
643
+ peerIds,
644
+ };
645
+ }
646
+
647
+ // ============ Step 5: Sync Delta ============
648
+
649
+ /**
650
+ * Build sync-delta payloads based on manifest comparison.
651
+ * May return multiple payloads for chunking.
652
+ */
653
+ async buildSyncDelta(
654
+ spaceId: string,
655
+ channelId: string,
656
+ theirManifest: SyncManifest,
657
+ theirMemberDigests: MemberDigest[],
658
+ theirPeerIds: number[],
659
+ ourPeerEntries: Map<number, PeerEntry>
660
+ ): Promise<SyncDeltaPayload[]> {
661
+ const cache = await this.getPayloadCache(spaceId, channelId);
662
+ const ourPeerIds = [...ourPeerEntries.keys()];
663
+
664
+ // Compute diffs using cached manifest and digests
665
+ const ourManifest = this.getManifest(cache);
666
+ const ourMemberDigests = this.getMemberDigests(cache);
667
+ const messageDiff = computeMessageDiff(ourManifest, theirManifest);
668
+ const memberDiff = computeMemberDiff(theirMemberDigests, ourMemberDigests);
669
+ const peerDiff = computePeerDiff(theirPeerIds, ourPeerIds);
670
+
671
+ // Build deltas using cached maps
672
+ const messageDelta = buildMessageDelta(
673
+ spaceId,
674
+ channelId,
675
+ messageDiff,
676
+ cache.messageMap,
677
+ this.tombstones
678
+ );
679
+
680
+ // Build reaction delta for messages they're missing or have outdated
681
+ const reactionMessageIds = [...messageDiff.extraIds, ...messageDiff.outdatedIds];
682
+ const reactionDelta = buildReactionDelta(spaceId, channelId, cache.messageMap, reactionMessageIds);
683
+
684
+ const memberDelta = buildMemberDelta(spaceId, memberDiff, cache.memberMap);
685
+
686
+ // Build peer map delta
687
+ const peerMapDelta: PeerMapDelta = {
688
+ spaceId,
689
+ added: peerDiff.extraPeerIds
690
+ .map((id) => ourPeerEntries.get(id))
691
+ .filter((e): e is PeerEntry => e !== undefined),
692
+ updated: [],
693
+ removed: [],
694
+ };
695
+
696
+ // Create payloads with chunking
697
+ const payloads: SyncDeltaPayload[] = [];
698
+ const allMessages = [...messageDelta.newMessages, ...messageDelta.updatedMessages];
699
+
700
+ if (allMessages.length > 0) {
701
+ const chunks = chunkMessages(allMessages);
702
+
703
+ for (let i = 0; i < chunks.length; i++) {
704
+ const chunk = chunks[i];
705
+ const isLast = i === chunks.length - 1;
706
+
707
+ const chunkDelta: MessageDelta = {
708
+ spaceId,
709
+ channelId,
710
+ newMessages: chunk.filter((m) =>
711
+ messageDiff.extraIds.includes(m.messageId)
712
+ ),
713
+ updatedMessages: chunk.filter((m) =>
714
+ messageDiff.outdatedIds.includes(m.messageId)
715
+ ),
716
+ deletedMessageIds: isLast ? messageDelta.deletedMessageIds : [],
717
+ };
718
+
719
+ payloads.push({
720
+ type: 'sync-delta',
721
+ messageDelta: chunkDelta,
722
+ // Include reaction delta only in last chunk
723
+ reactionDelta: isLast && reactionDelta.added.length > 0 ? reactionDelta : undefined,
724
+ isFinal: false,
725
+ });
726
+ }
727
+ }
728
+
729
+ // Add member and peer deltas
730
+ if (
731
+ memberDelta.members.length > 0 ||
732
+ peerMapDelta.added.length > 0 ||
733
+ allMessages.length === 0
734
+ ) {
735
+ payloads.push({
736
+ type: 'sync-delta',
737
+ memberDelta: memberDelta.members.length > 0 ? memberDelta : undefined,
738
+ peerMapDelta: peerMapDelta.added.length > 0 ? peerMapDelta : undefined,
739
+ isFinal: true,
740
+ });
741
+ } else if (payloads.length > 0) {
742
+ // Mark last message chunk as final
743
+ payloads[payloads.length - 1].isFinal = true;
744
+ }
745
+
746
+ // If no payloads, send empty final
747
+ if (payloads.length === 0) {
748
+ payloads.push({
749
+ type: 'sync-delta',
750
+ isFinal: true,
751
+ });
752
+ }
753
+
754
+ return payloads;
755
+ }
756
+
757
+ // ============ Delta Application ============
758
+
759
+ /**
760
+ * Apply received message delta to local storage
761
+ */
762
+ async applyMessageDelta(delta: MessageDelta): Promise<void> {
763
+ for (const msg of delta.newMessages) {
764
+ await this.storage.saveMessage(msg, msg.createdDate, '', '', '', '');
765
+ }
766
+
767
+ for (const msg of delta.updatedMessages) {
768
+ await this.storage.saveMessage(msg, msg.createdDate, '', '', '', '');
769
+ }
770
+
771
+ for (const id of delta.deletedMessageIds) {
772
+ await this.storage.deleteMessage(id);
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Apply received reaction delta to local storage.
778
+ * This updates the reactions on existing messages.
779
+ */
780
+ async applyReactionDelta(delta: ReactionDelta): Promise<void> {
781
+ for (const addition of delta.added) {
782
+ const message = await this.storage.getMessage({
783
+ spaceId: delta.spaceId,
784
+ channelId: delta.channelId,
785
+ messageId: addition.messageId,
786
+ });
787
+
788
+ if (message) {
789
+ const reactions = message.reactions || [];
790
+ const existing = reactions.find((r) => r.emojiId === addition.emojiId);
791
+
792
+ if (existing) {
793
+ // Merge member IDs
794
+ const allMembers = new Set([...existing.memberIds, ...addition.memberIds]);
795
+ existing.memberIds = [...allMembers];
796
+ existing.count = existing.memberIds.length;
797
+ } else {
798
+ // Add new reaction
799
+ reactions.push({
800
+ emojiId: addition.emojiId,
801
+ emojiName: addition.emojiId,
802
+ spaceId: delta.spaceId,
803
+ count: addition.memberIds.length,
804
+ memberIds: addition.memberIds,
805
+ });
806
+ }
807
+
808
+ message.reactions = reactions;
809
+ await this.storage.saveMessage(message, message.createdDate, '', '', '', '');
810
+ }
811
+ }
812
+
813
+ for (const removal of delta.removed) {
814
+ const message = await this.storage.getMessage({
815
+ spaceId: delta.spaceId,
816
+ channelId: delta.channelId,
817
+ messageId: removal.messageId,
818
+ });
819
+
820
+ if (message) {
821
+ const reactions = message.reactions || [];
822
+ const existing = reactions.find((r) => r.emojiId === removal.emojiId);
823
+
824
+ if (existing) {
825
+ // Remove member IDs
826
+ existing.memberIds = existing.memberIds.filter(
827
+ (id) => !removal.memberIds.includes(id)
828
+ );
829
+ existing.count = existing.memberIds.length;
830
+
831
+ // Remove reaction if no members left
832
+ if (existing.memberIds.length === 0) {
833
+ message.reactions = reactions.filter((r) => r.emojiId !== removal.emojiId);
834
+ }
835
+ }
836
+
837
+ await this.storage.saveMessage(message, message.createdDate, '', '', '', '');
838
+ }
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Apply received member delta to local storage
844
+ */
845
+ async applyMemberDelta(delta: MemberDelta): Promise<void> {
846
+ for (const member of delta.members) {
847
+ await this.storage.saveSpaceMember(delta.spaceId, member);
848
+ }
849
+
850
+ // Note: removed members would need storage support for deletion
851
+ }
852
+
853
+ /**
854
+ * Apply full sync delta
855
+ */
856
+ async applySyncDelta(delta: SyncDeltaPayload): Promise<void> {
857
+ if (delta.messageDelta) {
858
+ await this.applyMessageDelta(delta.messageDelta);
859
+ }
860
+
861
+ if (delta.reactionDelta) {
862
+ await this.applyReactionDelta(delta.reactionDelta);
863
+ }
864
+
865
+ if (delta.memberDelta) {
866
+ await this.applyMemberDelta(delta.memberDelta);
867
+ }
868
+
869
+ // Peer map delta is handled by encryption layer (caller responsibility)
870
+
871
+ // Clean up session if this is final
872
+ if (delta.isFinal) {
873
+ const spaceId = delta.messageDelta?.spaceId || delta.memberDelta?.spaceId;
874
+ if (spaceId) {
875
+ this.sessions.delete(spaceId);
876
+ }
877
+ }
878
+ }
879
+
880
+ // ============ Tombstone Management ============
881
+
882
+ /**
883
+ * Record a deleted message tombstone
884
+ */
885
+ addTombstone(tombstone: DeletedMessageTombstone): void {
886
+ this.tombstones.push(tombstone);
887
+ }
888
+
889
+ /**
890
+ * Get all tombstones (for persistence by caller)
891
+ */
892
+ getTombstones(): DeletedMessageTombstone[] {
893
+ return [...this.tombstones];
894
+ }
895
+
896
+ /**
897
+ * Load tombstones (from caller's persistence)
898
+ */
899
+ loadTombstones(tombstones: DeletedMessageTombstone[]): void {
900
+ this.tombstones = [...tombstones];
901
+ }
902
+
903
+ /**
904
+ * Clean up old tombstones (older than 30 days)
905
+ */
906
+ cleanupTombstones(maxAgeMs: number = 30 * 24 * 60 * 60 * 1000): void {
907
+ const cutoff = Date.now() - maxAgeMs;
908
+ this.tombstones = this.tombstones.filter((t) => t.deletedAt > cutoff);
909
+ }
910
+
911
+ // ============ Helpers ============
912
+
913
+ private async getChannelMessages(spaceId: string, channelId: string): Promise<Message[]> {
914
+ const result = await this.storage.getMessages({
915
+ spaceId,
916
+ channelId,
917
+ limit: this.maxMessages,
918
+ });
919
+ return result.messages;
920
+ }
921
+
922
+ /**
923
+ * Clean up expired sessions
924
+ */
925
+ cleanupSessions(): void {
926
+ const now = Date.now();
927
+ for (const [spaceId, session] of this.sessions) {
928
+ if (now > session.expiry) {
929
+ if (session.timeout) {
930
+ clearTimeout(session.timeout);
931
+ }
932
+ this.sessions.delete(spaceId);
933
+ }
934
+ }
935
+ }
936
+
937
+ /**
938
+ * Cancel active sync for a space
939
+ */
940
+ cancelSync(spaceId: string): void {
941
+ const session = this.sessions.get(spaceId);
942
+ if (session?.timeout) {
943
+ clearTimeout(session.timeout);
944
+ }
945
+ this.sessions.delete(spaceId);
946
+ }
947
+ }