@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.
- package/dist/index.d.mts +2414 -0
- package/dist/index.d.ts +2414 -0
- package/dist/index.js +2788 -0
- package/dist/index.mjs +2678 -0
- package/package.json +49 -0
- package/src/api/client.ts +86 -0
- package/src/api/endpoints.ts +87 -0
- package/src/api/errors.ts +179 -0
- package/src/api/index.ts +35 -0
- package/src/crypto/encryption-state.ts +249 -0
- package/src/crypto/index.ts +55 -0
- package/src/crypto/types.ts +307 -0
- package/src/crypto/wasm-provider.ts +298 -0
- package/src/hooks/index.ts +31 -0
- package/src/hooks/keys.ts +62 -0
- package/src/hooks/mutations/index.ts +15 -0
- package/src/hooks/mutations/useDeleteMessage.ts +67 -0
- package/src/hooks/mutations/useEditMessage.ts +87 -0
- package/src/hooks/mutations/useReaction.ts +163 -0
- package/src/hooks/mutations/useSendMessage.ts +131 -0
- package/src/hooks/useChannels.ts +49 -0
- package/src/hooks/useMessages.ts +77 -0
- package/src/hooks/useSpaces.ts +60 -0
- package/src/index.ts +32 -0
- package/src/signing/index.ts +10 -0
- package/src/signing/types.ts +83 -0
- package/src/signing/wasm-provider.ts +75 -0
- package/src/storage/adapter.ts +118 -0
- package/src/storage/index.ts +9 -0
- package/src/sync/index.ts +83 -0
- package/src/sync/service.test.ts +822 -0
- package/src/sync/service.ts +947 -0
- package/src/sync/types.ts +267 -0
- package/src/sync/utils.ts +588 -0
- package/src/transport/browser-websocket.ts +299 -0
- package/src/transport/index.ts +34 -0
- package/src/transport/rn-websocket.ts +321 -0
- package/src/transport/types.ts +56 -0
- package/src/transport/websocket.ts +212 -0
- package/src/types/bookmark.ts +29 -0
- package/src/types/conversation.ts +25 -0
- package/src/types/index.ts +57 -0
- package/src/types/message.ts +178 -0
- package/src/types/space.ts +75 -0
- package/src/types/user.ts +72 -0
- package/src/utils/encoding.ts +106 -0
- package/src/utils/formatting.ts +139 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/logger.ts +141 -0
- package/src/utils/mentions.ts +135 -0
- 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
|
+
}
|