@decentrl/sdk 0.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/client.d.ts +36 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +192 -0
- package/dist/contract-manager.d.ts +23 -0
- package/dist/contract-manager.d.ts.map +1 -0
- package/dist/contract-manager.js +91 -0
- package/dist/define-app.d.ts +8 -0
- package/dist/define-app.d.ts.map +1 -0
- package/dist/define-app.js +7 -0
- package/dist/direct-transport.d.ts +69 -0
- package/dist/direct-transport.d.ts.map +1 -0
- package/dist/direct-transport.js +450 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +10 -0
- package/dist/event-processor.d.ts +19 -0
- package/dist/event-processor.d.ts.map +1 -0
- package/dist/event-processor.js +93 -0
- package/dist/identity-manager.d.ts +22 -0
- package/dist/identity-manager.d.ts.map +1 -0
- package/dist/identity-manager.js +62 -0
- package/dist/identity-serialization.d.ts +5 -0
- package/dist/identity-serialization.d.ts.map +1 -0
- package/dist/identity-serialization.js +30 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/persistence.d.ts +11 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +82 -0
- package/dist/state-store.d.ts +12 -0
- package/dist/state-store.d.ts.map +1 -0
- package/dist/state-store.js +32 -0
- package/dist/sync-manager.d.ts +33 -0
- package/dist/sync-manager.d.ts.map +1 -0
- package/dist/sync-manager.js +244 -0
- package/dist/tag-templates.d.ts +2 -0
- package/dist/tag-templates.d.ts.map +1 -0
- package/dist/tag-templates.js +23 -0
- package/dist/test-helpers.d.ts +15 -0
- package/dist/test-helpers.d.ts.map +1 -0
- package/dist/test-helpers.js +65 -0
- package/dist/transport.d.ts +41 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +1 -0
- package/dist/types.d.ts +131 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/websocket-transport.d.ts +36 -0
- package/dist/websocket-transport.d.ts.map +1 -0
- package/dist/websocket-transport.js +160 -0
- package/package.json +35 -0
- package/src/client.ts +277 -0
- package/src/contract-manager.test.ts +207 -0
- package/src/contract-manager.ts +130 -0
- package/src/define-app.ts +25 -0
- package/src/direct-transport.test.ts +460 -0
- package/src/direct-transport.ts +729 -0
- package/src/errors.ts +23 -0
- package/src/event-processor.ts +133 -0
- package/src/identity-manager.ts +91 -0
- package/src/identity-serialization.ts +33 -0
- package/src/index.ts +43 -0
- package/src/persistence.ts +103 -0
- package/src/sdk.e2e.test.ts +367 -0
- package/src/state-store.ts +42 -0
- package/src/sync-manager.test.ts +414 -0
- package/src/sync-manager.ts +308 -0
- package/src/tag-templates.test.ts +111 -0
- package/src/tag-templates.ts +30 -0
- package/src/test-helpers.ts +88 -0
- package/src/transport.ts +65 -0
- package/src/types.ts +191 -0
- package/src/websocket-transport.ts +233 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { SyncManager } from './sync-manager.js';
|
|
3
|
+
import {
|
|
4
|
+
createMockContractManager,
|
|
5
|
+
createMockEventProcessor,
|
|
6
|
+
createMockIdentityManager,
|
|
7
|
+
createMockTransport,
|
|
8
|
+
} from './test-helpers.js';
|
|
9
|
+
import type { EventEnvelope } from './types.js';
|
|
10
|
+
|
|
11
|
+
describe('SyncManager', () => {
|
|
12
|
+
let syncManager: SyncManager<any, any>;
|
|
13
|
+
let transport: ReturnType<typeof createMockTransport>;
|
|
14
|
+
let eventProcessor: ReturnType<typeof createMockEventProcessor>;
|
|
15
|
+
let contractManager: ReturnType<typeof createMockContractManager>;
|
|
16
|
+
let identityManager: ReturnType<typeof createMockIdentityManager>;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.useFakeTimers();
|
|
20
|
+
transport = createMockTransport();
|
|
21
|
+
eventProcessor = createMockEventProcessor();
|
|
22
|
+
contractManager = createMockContractManager();
|
|
23
|
+
identityManager = createMockIdentityManager();
|
|
24
|
+
|
|
25
|
+
syncManager = new SyncManager(
|
|
26
|
+
eventProcessor,
|
|
27
|
+
contractManager as any,
|
|
28
|
+
identityManager as any,
|
|
29
|
+
transport,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
syncManager.stop();
|
|
35
|
+
vi.useRealTimers();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('tick', () => {
|
|
39
|
+
it('processes pending events and refreshes contracts', async () => {
|
|
40
|
+
const events: EventEnvelope[] = [
|
|
41
|
+
{
|
|
42
|
+
type: 'test.event',
|
|
43
|
+
data: { foo: 'bar' },
|
|
44
|
+
meta: { senderDid: 'did:a', timestamp: 1, eventId: 'e1' },
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
(transport.processPendingEvents as any).mockResolvedValue(events);
|
|
48
|
+
|
|
49
|
+
await syncManager.tick();
|
|
50
|
+
|
|
51
|
+
expect(transport.processPendingEvents as any).toHaveBeenCalled();
|
|
52
|
+
expect(eventProcessor.processBatch).toHaveBeenCalledWith(events);
|
|
53
|
+
expect(contractManager.refresh).toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('calls processAutoRenewals when autoRenew is enabled', async () => {
|
|
57
|
+
syncManager.start({
|
|
58
|
+
autoRenew: { enabled: true, threshold: 0.3 },
|
|
59
|
+
websocket: false,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Flush the initial tick (microtask queue) without advancing timers infinitely
|
|
63
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
64
|
+
|
|
65
|
+
expect(contractManager.processAutoRenewals).toHaveBeenCalledWith(0.3);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('skips processAutoRenewals when autoRenew is disabled', async () => {
|
|
69
|
+
await syncManager.tick();
|
|
70
|
+
|
|
71
|
+
expect(contractManager.processAutoRenewals).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('always calls processContractCleanup', async () => {
|
|
75
|
+
await syncManager.tick();
|
|
76
|
+
|
|
77
|
+
expect(contractManager.processContractCleanup).toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('isolates cleanup errors from the sync loop', async () => {
|
|
81
|
+
(contractManager.processContractCleanup as any).mockRejectedValue(new Error('cleanup boom'));
|
|
82
|
+
|
|
83
|
+
const onError = vi.fn();
|
|
84
|
+
await syncManager.tick(onError);
|
|
85
|
+
|
|
86
|
+
// The error should NOT propagate to onError — it's caught internally
|
|
87
|
+
expect(onError).not.toHaveBeenCalled();
|
|
88
|
+
expect(contractManager.processContractCleanup).toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('calls onError when event processing fails', async () => {
|
|
92
|
+
(transport.processPendingEvents as any).mockRejectedValue(new Error('network error'));
|
|
93
|
+
|
|
94
|
+
const onError = vi.fn();
|
|
95
|
+
await syncManager.tick(onError);
|
|
96
|
+
|
|
97
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('prevents concurrent ticks', async () => {
|
|
101
|
+
let resolveFirst: () => void;
|
|
102
|
+
const blockingPromise = new Promise<EventEnvelope[]>((resolve) => {
|
|
103
|
+
resolveFirst = () => resolve([]);
|
|
104
|
+
});
|
|
105
|
+
(transport.processPendingEvents as any).mockReturnValue(blockingPromise);
|
|
106
|
+
|
|
107
|
+
const tick1 = syncManager.tick();
|
|
108
|
+
const tick2 = syncManager.tick();
|
|
109
|
+
|
|
110
|
+
resolveFirst!();
|
|
111
|
+
await tick1;
|
|
112
|
+
await tick2;
|
|
113
|
+
|
|
114
|
+
// Should only have been called once due to isSyncing guard
|
|
115
|
+
expect(transport.processPendingEvents as any).toHaveBeenCalledTimes(1);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('start / stop', () => {
|
|
120
|
+
it('starts polling and stops cleanly', async () => {
|
|
121
|
+
syncManager.start({ intervalMs: 1000, websocket: false });
|
|
122
|
+
|
|
123
|
+
expect(syncManager.isRunning).toBe(true);
|
|
124
|
+
|
|
125
|
+
syncManager.stop();
|
|
126
|
+
|
|
127
|
+
expect(syncManager.isRunning).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('transport mode', () => {
|
|
132
|
+
it('uses transport for event processing', async () => {
|
|
133
|
+
const transportEvents: EventEnvelope[] = [
|
|
134
|
+
{
|
|
135
|
+
type: 'transport.event',
|
|
136
|
+
data: {},
|
|
137
|
+
meta: { senderDid: 'did:b', timestamp: 2, eventId: 'e2' },
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
(transport.processPendingEvents as any).mockResolvedValue(transportEvents);
|
|
142
|
+
|
|
143
|
+
await syncManager.tick();
|
|
144
|
+
|
|
145
|
+
expect(transport.processPendingEvents).toHaveBeenCalled();
|
|
146
|
+
expect(eventProcessor.processBatch).toHaveBeenCalledWith(transportEvents);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('processUntaggedEvents', () => {
|
|
151
|
+
const withTransport = (mockTransport: any) =>
|
|
152
|
+
new SyncManager(
|
|
153
|
+
eventProcessor,
|
|
154
|
+
contractManager as any,
|
|
155
|
+
identityManager as any,
|
|
156
|
+
mockTransport,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
it('enriches unprocessed events with tags via transport on tick', async () => {
|
|
160
|
+
const untaggedEvents: EventEnvelope[] = [
|
|
161
|
+
{
|
|
162
|
+
type: 'chat.message',
|
|
163
|
+
data: { chatId: 'c1' },
|
|
164
|
+
meta: { senderDid: 'did:b', timestamp: 1, eventId: 'e1' },
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
const mockTransport = {
|
|
169
|
+
processPendingEvents: vi.fn(async () => [
|
|
170
|
+
{
|
|
171
|
+
type: 'chat.message',
|
|
172
|
+
data: { chatId: 'c1' },
|
|
173
|
+
meta: { senderDid: 'did:b', timestamp: 1, eventId: 'e1' },
|
|
174
|
+
},
|
|
175
|
+
]),
|
|
176
|
+
queryUnprocessedEvents: vi.fn(async () => ({
|
|
177
|
+
data: untaggedEvents.map((e) => ({ ...e, _mediatorEventId: 'med-1' })),
|
|
178
|
+
pagination: { page: 0, pageSize: 50, total: 1 },
|
|
179
|
+
})),
|
|
180
|
+
updateEventTags: vi.fn(async () => {}),
|
|
181
|
+
};
|
|
182
|
+
const sm = withTransport(mockTransport);
|
|
183
|
+
|
|
184
|
+
await sm.tick();
|
|
185
|
+
|
|
186
|
+
expect(mockTransport.queryUnprocessedEvents).toHaveBeenCalled();
|
|
187
|
+
expect(mockTransport.updateEventTags).toHaveBeenCalledWith([
|
|
188
|
+
{ eventId: 'med-1', tags: ['tag:chat.message'] },
|
|
189
|
+
]);
|
|
190
|
+
sm.stop();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('skips tag update when no events are processed', async () => {
|
|
194
|
+
const mockTransport = {
|
|
195
|
+
processPendingEvents: vi.fn(async () => []),
|
|
196
|
+
queryUnprocessedEvents: vi.fn(async () => ({
|
|
197
|
+
data: [],
|
|
198
|
+
pagination: { page: 0, pageSize: 50, total: 0 },
|
|
199
|
+
})),
|
|
200
|
+
updateEventTags: vi.fn(async () => {}),
|
|
201
|
+
};
|
|
202
|
+
const sm = withTransport(mockTransport);
|
|
203
|
+
|
|
204
|
+
await sm.tick();
|
|
205
|
+
|
|
206
|
+
// No events processed → processUntaggedEvents not called
|
|
207
|
+
expect(mockTransport.queryUnprocessedEvents).not.toHaveBeenCalled();
|
|
208
|
+
sm.stop();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('uses _mediatorEventId when available, falls back to meta.eventId', async () => {
|
|
212
|
+
const mockTransport = {
|
|
213
|
+
processPendingEvents: vi.fn(async () => [
|
|
214
|
+
{ type: 'a', data: {}, meta: { senderDid: 'did:b', timestamp: 1, eventId: 'e1' } },
|
|
215
|
+
]),
|
|
216
|
+
queryUnprocessedEvents: vi.fn(async () => ({
|
|
217
|
+
data: [
|
|
218
|
+
// Event with _mediatorEventId
|
|
219
|
+
{
|
|
220
|
+
type: 'a',
|
|
221
|
+
data: {},
|
|
222
|
+
meta: { senderDid: 'did:b', timestamp: 1, eventId: 'client-uuid' },
|
|
223
|
+
_mediatorEventId: 'mediator-cuid',
|
|
224
|
+
},
|
|
225
|
+
// Event without _mediatorEventId
|
|
226
|
+
{
|
|
227
|
+
type: 'b',
|
|
228
|
+
data: {},
|
|
229
|
+
meta: { senderDid: 'did:c', timestamp: 2, eventId: 'client-uuid-2' },
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
pagination: { page: 0, pageSize: 50, total: 2 },
|
|
233
|
+
})),
|
|
234
|
+
updateEventTags: vi.fn(async () => {}),
|
|
235
|
+
};
|
|
236
|
+
const sm = withTransport(mockTransport);
|
|
237
|
+
|
|
238
|
+
await sm.tick();
|
|
239
|
+
|
|
240
|
+
expect(mockTransport.updateEventTags).toHaveBeenCalledWith([
|
|
241
|
+
{ eventId: 'mediator-cuid', tags: ['tag:a'] },
|
|
242
|
+
{ eventId: 'client-uuid-2', tags: ['tag:b'] },
|
|
243
|
+
]);
|
|
244
|
+
sm.stop();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('skips ephemeral events for tag updates', async () => {
|
|
248
|
+
const mockTransport = {
|
|
249
|
+
processPendingEvents: vi.fn(async () => [
|
|
250
|
+
{ type: 'a', data: {}, meta: { senderDid: 'did:b', timestamp: 1, eventId: 'e1' } },
|
|
251
|
+
]),
|
|
252
|
+
queryUnprocessedEvents: vi.fn(async () => ({
|
|
253
|
+
data: [
|
|
254
|
+
{
|
|
255
|
+
type: 'a',
|
|
256
|
+
data: {},
|
|
257
|
+
meta: { senderDid: 'did:b', timestamp: 1, eventId: 'e1', ephemeral: true },
|
|
258
|
+
_mediatorEventId: 'med-1',
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
pagination: { page: 0, pageSize: 50, total: 1 },
|
|
262
|
+
})),
|
|
263
|
+
updateEventTags: vi.fn(async () => {}),
|
|
264
|
+
};
|
|
265
|
+
const sm = withTransport(mockTransport);
|
|
266
|
+
|
|
267
|
+
await sm.tick();
|
|
268
|
+
|
|
269
|
+
// Ephemeral events are filtered out — no tag updates
|
|
270
|
+
expect(mockTransport.updateEventTags).not.toHaveBeenCalled();
|
|
271
|
+
sm.stop();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('paginates through unprocessed events', async () => {
|
|
275
|
+
const page0Events = Array.from({ length: 50 }, (_, i) => ({
|
|
276
|
+
type: 'a',
|
|
277
|
+
data: {},
|
|
278
|
+
meta: { senderDid: 'did:b', timestamp: i, eventId: `e${i}` },
|
|
279
|
+
_mediatorEventId: `med-${i}`,
|
|
280
|
+
}));
|
|
281
|
+
|
|
282
|
+
const page1Events = [
|
|
283
|
+
{
|
|
284
|
+
type: 'a',
|
|
285
|
+
data: {},
|
|
286
|
+
meta: { senderDid: 'did:b', timestamp: 50, eventId: 'e50' },
|
|
287
|
+
_mediatorEventId: 'med-50',
|
|
288
|
+
},
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
const mockTransport = {
|
|
292
|
+
processPendingEvents: vi.fn(async () => [
|
|
293
|
+
{ type: 'a', data: {}, meta: { senderDid: 'did:b', timestamp: 1, eventId: 'trigger' } },
|
|
294
|
+
]),
|
|
295
|
+
queryUnprocessedEvents: vi
|
|
296
|
+
.fn()
|
|
297
|
+
.mockResolvedValueOnce({
|
|
298
|
+
data: page0Events,
|
|
299
|
+
pagination: { page: 0, pageSize: 50, total: 51 },
|
|
300
|
+
})
|
|
301
|
+
.mockResolvedValueOnce({
|
|
302
|
+
data: page1Events,
|
|
303
|
+
pagination: { page: 1, pageSize: 50, total: 51 },
|
|
304
|
+
}),
|
|
305
|
+
updateEventTags: vi.fn(async () => {}),
|
|
306
|
+
};
|
|
307
|
+
const sm = withTransport(mockTransport);
|
|
308
|
+
|
|
309
|
+
await sm.tick();
|
|
310
|
+
|
|
311
|
+
expect(mockTransport.queryUnprocessedEvents).toHaveBeenCalledTimes(2);
|
|
312
|
+
expect(mockTransport.updateEventTags).toHaveBeenCalledTimes(2);
|
|
313
|
+
sm.stop();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe('push handler', () => {
|
|
318
|
+
const withTransport = (mockTransport: any) =>
|
|
319
|
+
new SyncManager(
|
|
320
|
+
eventProcessor,
|
|
321
|
+
contractManager as any,
|
|
322
|
+
identityManager as any,
|
|
323
|
+
mockTransport,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
it('triggers processUntaggedEvents for non-ephemeral pushed events', async () => {
|
|
327
|
+
let pushCallback: (events: EventEnvelope[]) => void;
|
|
328
|
+
|
|
329
|
+
const mockTransport = {
|
|
330
|
+
onEvents: vi.fn((cb: (events: EventEnvelope[]) => void) => {
|
|
331
|
+
pushCallback = cb;
|
|
332
|
+
|
|
333
|
+
return () => {};
|
|
334
|
+
}),
|
|
335
|
+
queryUnprocessedEvents: vi.fn(async () => ({
|
|
336
|
+
data: [
|
|
337
|
+
{
|
|
338
|
+
type: 'chat.message',
|
|
339
|
+
data: {},
|
|
340
|
+
meta: { senderDid: 'did:b', timestamp: 1, eventId: 'e1' },
|
|
341
|
+
_mediatorEventId: 'med-1',
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
pagination: { page: 0, pageSize: 50, total: 1 },
|
|
345
|
+
})),
|
|
346
|
+
updateEventTags: vi.fn(async () => {}),
|
|
347
|
+
processPendingEvents: vi.fn(async () => []),
|
|
348
|
+
};
|
|
349
|
+
const sm = withTransport(mockTransport);
|
|
350
|
+
|
|
351
|
+
sm.start({ websocket: false });
|
|
352
|
+
|
|
353
|
+
// Wait for initial tick
|
|
354
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
355
|
+
|
|
356
|
+
// Reset to isolate push handler behavior
|
|
357
|
+
mockTransport.queryUnprocessedEvents.mockClear();
|
|
358
|
+
mockTransport.updateEventTags.mockClear();
|
|
359
|
+
|
|
360
|
+
// Simulate push
|
|
361
|
+
pushCallback!([
|
|
362
|
+
{
|
|
363
|
+
type: 'chat.message',
|
|
364
|
+
data: {},
|
|
365
|
+
meta: { senderDid: 'did:b', timestamp: 2, eventId: 'e2' },
|
|
366
|
+
},
|
|
367
|
+
]);
|
|
368
|
+
|
|
369
|
+
// processUntaggedEvents is async, give it a tick
|
|
370
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
371
|
+
|
|
372
|
+
expect(mockTransport.queryUnprocessedEvents).toHaveBeenCalled();
|
|
373
|
+
sm.stop();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('does NOT trigger processUntaggedEvents for ephemeral-only pushes', async () => {
|
|
377
|
+
let pushCallback: (events: EventEnvelope[]) => void;
|
|
378
|
+
|
|
379
|
+
const mockTransport = {
|
|
380
|
+
onEvents: vi.fn((cb: (events: EventEnvelope[]) => void) => {
|
|
381
|
+
pushCallback = cb;
|
|
382
|
+
|
|
383
|
+
return () => {};
|
|
384
|
+
}),
|
|
385
|
+
queryUnprocessedEvents: vi.fn(async () => ({
|
|
386
|
+
data: [],
|
|
387
|
+
pagination: { page: 0, pageSize: 50, total: 0 },
|
|
388
|
+
})),
|
|
389
|
+
updateEventTags: vi.fn(async () => {}),
|
|
390
|
+
processPendingEvents: vi.fn(async () => []),
|
|
391
|
+
};
|
|
392
|
+
const sm = withTransport(mockTransport);
|
|
393
|
+
|
|
394
|
+
sm.start({ websocket: false });
|
|
395
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
396
|
+
|
|
397
|
+
mockTransport.queryUnprocessedEvents.mockClear();
|
|
398
|
+
|
|
399
|
+
// Push ephemeral-only events
|
|
400
|
+
pushCallback!([
|
|
401
|
+
{
|
|
402
|
+
type: 'typing',
|
|
403
|
+
data: {},
|
|
404
|
+
meta: { senderDid: 'did:b', timestamp: 2, eventId: 'e2', ephemeral: true },
|
|
405
|
+
},
|
|
406
|
+
]);
|
|
407
|
+
|
|
408
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
409
|
+
|
|
410
|
+
expect(mockTransport.queryUnprocessedEvents).not.toHaveBeenCalled();
|
|
411
|
+
sm.stop();
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type { ContractManager } from './contract-manager.js';
|
|
2
|
+
import type { EventProcessor } from './event-processor.js';
|
|
3
|
+
import type { IdentityManager } from './identity-manager.js';
|
|
4
|
+
import type { DecentrlTransport } from './transport.js';
|
|
5
|
+
import type { EventDefinitions, EventEnvelope, StateDefinitions, SyncOptions } from './types.js';
|
|
6
|
+
import { type ConnectionStatus, deriveWsUrl, WebSocketTransport } from './websocket-transport.js';
|
|
7
|
+
|
|
8
|
+
type StatusListener = (status: ConnectionStatus) => void;
|
|
9
|
+
|
|
10
|
+
export class SyncManager<
|
|
11
|
+
TEvents extends EventDefinitions,
|
|
12
|
+
TState extends StateDefinitions<TEvents>,
|
|
13
|
+
> {
|
|
14
|
+
private intervalId: ReturnType<typeof setInterval> | null = null;
|
|
15
|
+
private isSyncing = false;
|
|
16
|
+
private wsTransport: WebSocketTransport | null = null;
|
|
17
|
+
private connectionStatus: ConnectionStatus = 'disconnected';
|
|
18
|
+
private statusListeners = new Set<StatusListener>();
|
|
19
|
+
private unsubscribePush: (() => void) | null = null;
|
|
20
|
+
private unsubscribeContractsPush: (() => void) | null = null;
|
|
21
|
+
private currentOptions: SyncOptions = {};
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
private eventProcessor: EventProcessor<TEvents, TState>,
|
|
25
|
+
private contractManager: ContractManager,
|
|
26
|
+
private identityManager: IdentityManager,
|
|
27
|
+
private transport: DecentrlTransport,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
start(options: SyncOptions = {}): void {
|
|
31
|
+
this.stop();
|
|
32
|
+
this.currentOptions = options;
|
|
33
|
+
|
|
34
|
+
// If transport supports push-based events, subscribe
|
|
35
|
+
if (this.transport.onEvents) {
|
|
36
|
+
this.unsubscribePush = this.transport.onEvents((events) => {
|
|
37
|
+
try {
|
|
38
|
+
this.eventProcessor.processBatch(events);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error('[Decentrl] Failed to process pushed events:', err);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Tag enrichment for non-ephemeral pushed events
|
|
44
|
+
if (events.some((e) => !e.meta.ephemeral)) {
|
|
45
|
+
this.processUntaggedEvents().catch((err) =>
|
|
46
|
+
console.debug('[Decentrl] Failed to process untagged events:', err),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (this.transport.onContractsChanged) {
|
|
52
|
+
this.unsubscribeContractsPush = this.transport.onContractsChanged(() => {
|
|
53
|
+
this.contractManager.refresh();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Still do an initial sync tick + slower polling for contracts
|
|
58
|
+
this.tick(options.onError);
|
|
59
|
+
const intervalMs = options.fallbackIntervalMs ?? 30_000;
|
|
60
|
+
this.intervalId = setInterval(() => {
|
|
61
|
+
this.tick(options.onError);
|
|
62
|
+
}, intervalMs);
|
|
63
|
+
|
|
64
|
+
// Load stored events from mediator to catch up on offline period
|
|
65
|
+
this.loadStoredEvents().catch((err) =>
|
|
66
|
+
console.debug('[Decentrl] loadStoredEvents() failed:', err),
|
|
67
|
+
);
|
|
68
|
+
// Process any events that haven't been tagged by the app yet
|
|
69
|
+
this.processUntaggedEvents().catch((err) =>
|
|
70
|
+
console.debug('[Decentrl] processUntaggedEvents() failed:', err),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const intervalMs = options.intervalMs ?? 3000;
|
|
77
|
+
|
|
78
|
+
// Run immediately
|
|
79
|
+
this.tick(options.onError);
|
|
80
|
+
|
|
81
|
+
this.intervalId = setInterval(() => {
|
|
82
|
+
this.tick(options.onError);
|
|
83
|
+
}, intervalMs);
|
|
84
|
+
|
|
85
|
+
// Start WebSocket if available and enabled
|
|
86
|
+
const useWebSocket = options.websocket !== false;
|
|
87
|
+
|
|
88
|
+
if (useWebSocket && typeof globalThis.WebSocket !== 'undefined') {
|
|
89
|
+
this.startWebSocket(options);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
stop(): void {
|
|
94
|
+
if (this.intervalId !== null) {
|
|
95
|
+
clearInterval(this.intervalId);
|
|
96
|
+
this.intervalId = null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (this.wsTransport) {
|
|
100
|
+
this.wsTransport.disconnect();
|
|
101
|
+
this.wsTransport = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (this.unsubscribePush) {
|
|
105
|
+
this.unsubscribePush();
|
|
106
|
+
this.unsubscribePush = null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (this.unsubscribeContractsPush) {
|
|
110
|
+
this.unsubscribeContractsPush();
|
|
111
|
+
this.unsubscribeContractsPush = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async tick(onError?: (error: unknown) => void): Promise<void> {
|
|
116
|
+
if (this.isSyncing) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
this.isSyncing = true;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const processedEvents = await this.transport.processPendingEvents();
|
|
124
|
+
this.eventProcessor.processBatch(processedEvents);
|
|
125
|
+
|
|
126
|
+
// Enrich any unprocessed events with app-computed tags
|
|
127
|
+
if (processedEvents.length > 0) {
|
|
128
|
+
await this.processUntaggedEvents();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Refresh contracts
|
|
132
|
+
await this.contractManager.refresh();
|
|
133
|
+
|
|
134
|
+
// Auto-renew expiring contracts
|
|
135
|
+
const autoRenew = this.currentOptions.autoRenew;
|
|
136
|
+
|
|
137
|
+
if (autoRenew?.enabled) {
|
|
138
|
+
await this.contractManager.processAutoRenewals(autoRenew.threshold);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Clean up old superseded contracts
|
|
142
|
+
try {
|
|
143
|
+
await this.contractManager.processContractCleanup();
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.error('[Decentrl] Contract cleanup failed:', err);
|
|
146
|
+
}
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (onError) {
|
|
149
|
+
onError(error);
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
this.isSyncing = false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
getConnectionStatus = (): ConnectionStatus => {
|
|
157
|
+
return this.connectionStatus;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
onConnectionStatusChange = (listener: StatusListener): (() => void) => {
|
|
161
|
+
this.statusListeners.add(listener);
|
|
162
|
+
|
|
163
|
+
return () => {
|
|
164
|
+
this.statusListeners.delete(listener);
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
get isRunning(): boolean {
|
|
169
|
+
return this.intervalId !== null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async loadStoredEvents(): Promise<void> {
|
|
173
|
+
if (!this.transport.queryEvents) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
let page = 0;
|
|
179
|
+
const pageSize = 50;
|
|
180
|
+
|
|
181
|
+
while (true) {
|
|
182
|
+
const result = await this.transport.queryEvents({
|
|
183
|
+
pagination: { page, pageSize },
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (result.data.length > 0) {
|
|
187
|
+
this.eventProcessor.processBatch(result.data as EventEnvelope[]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if ((page + 1) * pageSize >= result.pagination.total) {
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
page++;
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.debug('[Decentrl] loadStoredEvents() failed:', err);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async processUntaggedEvents(): Promise<void> {
|
|
202
|
+
if (!this.transport.queryUnprocessedEvents) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!this.transport.updateEventTags) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
let page = 0;
|
|
212
|
+
const pageSize = 50;
|
|
213
|
+
|
|
214
|
+
while (true) {
|
|
215
|
+
const result = await this.transport.queryUnprocessedEvents({ page, pageSize });
|
|
216
|
+
|
|
217
|
+
if (result.data.length === 0) {
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.eventProcessor.processBatch(result.data as EventEnvelope[]);
|
|
222
|
+
|
|
223
|
+
const tagUpdates = result.data
|
|
224
|
+
.filter((e) => !e.meta.ephemeral)
|
|
225
|
+
.map((e) => ({
|
|
226
|
+
eventId: e._mediatorEventId ?? e.meta.eventId,
|
|
227
|
+
tags: this.eventProcessor.computeTagsSafe(e.type, e.data),
|
|
228
|
+
}));
|
|
229
|
+
|
|
230
|
+
if (tagUpdates.length > 0) {
|
|
231
|
+
await this.transport.updateEventTags(tagUpdates);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if ((page + 1) * pageSize >= result.pagination.total) {
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
page++;
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.debug('[Decentrl] processUntaggedEvents failed:', err);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private startWebSocket(options: SyncOptions): void {
|
|
246
|
+
const identity = this.identityManager.getIdentity();
|
|
247
|
+
|
|
248
|
+
if (!identity) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const wsUrl = deriveWsUrl(identity.mediatorEndpoint);
|
|
253
|
+
|
|
254
|
+
this.wsTransport = new WebSocketTransport(
|
|
255
|
+
() => {
|
|
256
|
+
const id = this.identityManager.getIdentity();
|
|
257
|
+
|
|
258
|
+
return id ? { did: id.did, keys: id.keys } : null;
|
|
259
|
+
},
|
|
260
|
+
() => wsUrl,
|
|
261
|
+
{
|
|
262
|
+
onPendingEvents: async (events) => {
|
|
263
|
+
try {
|
|
264
|
+
if (this.transport.processPreFetchedPendingEvents) {
|
|
265
|
+
const processed = await this.transport.processPreFetchedPendingEvents(events);
|
|
266
|
+
this.eventProcessor.processBatch(processed);
|
|
267
|
+
}
|
|
268
|
+
} catch (error) {
|
|
269
|
+
options.onError?.(error);
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
onContractsUpdated: async () => {
|
|
273
|
+
try {
|
|
274
|
+
await this.contractManager.refresh();
|
|
275
|
+
} catch (error) {
|
|
276
|
+
options.onError?.(error);
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
onStatusChange: (status) => {
|
|
280
|
+
this.connectionStatus = status;
|
|
281
|
+
|
|
282
|
+
// Adjust polling interval based on WS connection
|
|
283
|
+
if (this.intervalId !== null) {
|
|
284
|
+
clearInterval(this.intervalId);
|
|
285
|
+
const intervalMs =
|
|
286
|
+
status === 'connected'
|
|
287
|
+
? (options.fallbackIntervalMs ?? 30_000)
|
|
288
|
+
: (options.intervalMs ?? 3000);
|
|
289
|
+
this.intervalId = setInterval(() => {
|
|
290
|
+
this.tick(options.onError);
|
|
291
|
+
}, intervalMs);
|
|
292
|
+
|
|
293
|
+
// Trigger immediate catch-up tick when WS connects
|
|
294
|
+
if (status === 'connected') {
|
|
295
|
+
this.tick(options.onError);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
for (const listener of this.statusListeners) {
|
|
300
|
+
listener(status);
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
this.wsTransport.connect();
|
|
307
|
+
}
|
|
308
|
+
}
|