document-drive 1.0.0-experimental.79 → 1.0.0-experimental.80

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [1.0.0-alpha.79](https://github.com/powerhouse-inc/document-drive/compare/v1.0.0-alpha.78...v1.0.0-alpha.79) (2024-06-24)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * do not store resulting state in cache ([f5ca275](https://github.com/powerhouse-inc/document-drive/commit/f5ca27549a1ef36cbb7600b47ae27e3d53a94c33))
7
+
1
8
  # [1.0.0-alpha.78](https://github.com/powerhouse-inc/document-drive/compare/v1.0.0-alpha.77...v1.0.0-alpha.78) (2024-06-20)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "document-drive",
3
- "version": "1.0.0-experimental.79",
3
+ "version": "1.0.0-experimental.80",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "module": "./src/index.ts",
@@ -66,6 +66,7 @@ import {
66
66
  import {
67
67
  AddOperationOptions,
68
68
  BaseDocumentDriveServer,
69
+ BaseListenerManager,
69
70
  DriveEvents,
70
71
  GetDocumentOptions,
71
72
  IOperationResult,
@@ -81,6 +82,8 @@ import {
81
82
  type SynchronizationUnit
82
83
  } from './types';
83
84
  import { filterOperationsByRevision } from './utils';
85
+ import { RedisListenerManager } from './listener/redis-manager';
86
+ import { RedisClientType } from 'redis';
84
87
 
85
88
  export * from './listener';
86
89
  export type * from './types';
@@ -105,10 +108,12 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
105
108
  documentModels: DocumentModel[],
106
109
  storage: IDriveStorage = new MemoryStorage(),
107
110
  cache: ICache = new InMemoryCache(),
108
- queueManager: IQueueManager = new BaseQueueManager()
111
+ queueManager: IQueueManager = new BaseQueueManager(),
112
+ redisClient: RedisClientType | undefined = undefined
109
113
  ) {
110
114
  super();
111
- this.listenerStateManager = new ListenerManager(this);
115
+ this.listenerStateManager = redisClient ? new RedisListenerManager(this, redisClient) : new ListenerManager(this);
116
+
112
117
  this.documentModels = documentModels;
113
118
  this.storage = storage;
114
119
  this.cache = cache;
@@ -149,16 +154,16 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
149
154
 
150
155
  const result = await (!strand.documentId
151
156
  ? this.queueDriveOperations(
152
- strand.driveId,
153
- operations as Operation<DocumentDriveAction | BaseAction>[],
154
- { source }
155
- )
157
+ strand.driveId,
158
+ operations as Operation<DocumentDriveAction | BaseAction>[],
159
+ { source }
160
+ )
156
161
  : this.queueOperations(
157
- strand.driveId,
158
- strand.documentId,
159
- operations,
160
- { source }
161
- ));
162
+ strand.driveId,
163
+ strand.documentId,
164
+ operations,
165
+ { source }
166
+ ));
162
167
 
163
168
  if (result.status === 'ERROR') {
164
169
  this.updateSyncStatus(strand.driveId, result.status, result.error);
@@ -290,12 +295,12 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
290
295
  return documentId
291
296
  ? this.addOperations(driveId, documentId, operations, options)
292
297
  : this.addDriveOperations(
293
- driveId,
294
- operations as Operation<
295
- DocumentDriveAction | BaseAction
296
- >[],
297
- options
298
- );
298
+ driveId,
299
+ operations as Operation<
300
+ DocumentDriveAction | BaseAction
301
+ >[],
302
+ options
303
+ );
299
304
  },
300
305
  processActionJob: async ({
301
306
  driveId,
@@ -306,10 +311,10 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
306
311
  return documentId
307
312
  ? this.addActions(driveId, documentId, actions, options)
308
313
  : this.addDriveActions(
309
- driveId,
310
- actions as Operation<DocumentDriveAction | BaseAction>[],
311
- options
312
- );
314
+ driveId,
315
+ actions as Operation<DocumentDriveAction | BaseAction>[],
316
+ options
317
+ );
313
318
  },
314
319
  processJob: async (job: Job) => {
315
320
  if (isOperationJob(job)) {
@@ -462,14 +467,14 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
462
467
  const nodeUnits =
463
468
  scope?.length || branch?.length
464
469
  ? node.synchronizationUnits.filter(
465
- unit =>
466
- (!scope?.length ||
467
- scope.includes(unit.scope) ||
468
- scope.includes('*')) &&
469
- (!branch?.length ||
470
- branch.includes(unit.branch) ||
471
- branch.includes('*'))
472
- )
470
+ unit =>
471
+ (!scope?.length ||
472
+ scope.includes(unit.scope) ||
473
+ scope.includes('*')) &&
474
+ (!branch?.length ||
475
+ branch.includes(unit.branch) ||
476
+ branch.includes('*'))
477
+ )
473
478
  : node.synchronizationUnits;
474
479
  if (!nodeUnits.length) {
475
480
  continue;
@@ -902,11 +907,11 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
902
907
  e instanceof OperationError
903
908
  ? e
904
909
  : new OperationError(
905
- 'ERROR',
906
- nextOperation,
907
- (e as Error).message,
908
- (e as Error).cause
909
- );
910
+ 'ERROR',
911
+ nextOperation,
912
+ (e as Error).message,
913
+ (e as Error).cause
914
+ );
910
915
 
911
916
  // TODO: don't break on errors...
912
917
  break;
@@ -940,9 +945,9 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
940
945
  const revisionOperations =
941
946
  options?.revisions !== undefined
942
947
  ? filterOperationsByRevision(
943
- documentStorage.operations,
944
- options.revisions
945
- )
948
+ documentStorage.operations,
949
+ options.revisions
950
+ )
946
951
  : documentStorage.operations;
947
952
  const operations =
948
953
  baseUtils.documentHelpers.garbageCollectDocumentOperations(
@@ -992,18 +997,18 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
992
997
  if (lastRemainingOperation && !lastRemainingOperation.resultingState) {
993
998
  lastRemainingOperation.resultingState = await (id
994
999
  ? this.storage.getOperationResultingState?.(
995
- drive,
996
- id,
997
- lastRemainingOperation.index,
998
- lastRemainingOperation.scope,
999
- 'main'
1000
- )
1000
+ drive,
1001
+ id,
1002
+ lastRemainingOperation.index,
1003
+ lastRemainingOperation.scope,
1004
+ 'main'
1005
+ )
1001
1006
  : this.storage.getDriveOperationResultingState?.(
1002
- drive,
1003
- lastRemainingOperation.index,
1004
- lastRemainingOperation.scope,
1005
- 'main'
1006
- ));
1007
+ drive,
1008
+ lastRemainingOperation.index,
1009
+ lastRemainingOperation.scope,
1010
+ 'main'
1011
+ ));
1007
1012
  }
1008
1013
 
1009
1014
  const operationSignals: (() => Promise<SignalResult>)[] = [];
@@ -1446,11 +1451,11 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
1446
1451
  error instanceof OperationError
1447
1452
  ? error
1448
1453
  : new OperationError(
1449
- 'ERROR',
1450
- undefined,
1451
- (error as Error).message,
1452
- (error as Error).cause
1453
- );
1454
+ 'ERROR',
1455
+ undefined,
1456
+ (error as Error).message,
1457
+ (error as Error).cause
1458
+ );
1454
1459
 
1455
1460
  return {
1456
1461
  status: operationError.status,
@@ -1778,11 +1783,11 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
1778
1783
  error instanceof OperationError
1779
1784
  ? error
1780
1785
  : new OperationError(
1781
- 'ERROR',
1782
- undefined,
1783
- (error as Error).message,
1784
- (error as Error).cause
1785
- );
1786
+ 'ERROR',
1787
+ undefined,
1788
+ (error as Error).message,
1789
+ (error as Error).cause
1790
+ );
1786
1791
 
1787
1792
  return {
1788
1793
  status: operationError.status,
@@ -0,0 +1,473 @@
1
+ import {
2
+ DocumentDriveDocument,
3
+ ListenerFilter
4
+ } from 'document-model-libs/document-drive';
5
+ import { OperationScope } from 'document-model/document';
6
+ import { logger } from '../../utils/logger';
7
+ import { OperationError } from '../error';
8
+ import {
9
+ BaseDocumentDriveServer,
10
+ BaseListenerManager,
11
+ ErrorStatus,
12
+ Listener,
13
+ ListenerState,
14
+ ListenerUpdate,
15
+ OperationUpdate,
16
+ StrandUpdate,
17
+ SynchronizationUnit
18
+ } from '../types';
19
+ import { PullResponderTransmitter } from './transmitter';
20
+ import { InternalTransmitter } from './transmitter/internal';
21
+ import { SwitchboardPushTransmitter } from './transmitter/switchboard-push';
22
+ import { ITransmitter, StrandUpdateSource } from './transmitter/types';
23
+ import { RedisClientType } from "redis";
24
+ import { ListenerManager } from './manager';
25
+
26
+ function debounce<T extends unknown[], R>(
27
+ func: (...args: T) => Promise<R>,
28
+ delay = 250
29
+ ) {
30
+ let timer: number;
31
+ return (immediate: boolean, ...args: T) => {
32
+ if (timer) {
33
+ clearTimeout(timer);
34
+ }
35
+ return new Promise<R>((resolve, reject) => {
36
+ if (immediate) {
37
+ func(...args)
38
+ .then(resolve)
39
+ .catch(reject);
40
+ } else {
41
+ timer = setTimeout(() => {
42
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
43
+ func(...args)
44
+ .then(resolve)
45
+ .catch(reject);
46
+ }, delay) as unknown as number;
47
+ }
48
+ });
49
+ };
50
+ }
51
+
52
+ export class RedisListenerManager extends ListenerManager {
53
+ static LISTENER_UPDATE_DELAY = 250;
54
+
55
+ private redisClient: RedisClientType
56
+
57
+ constructor(
58
+ drive: BaseDocumentDriveServer,
59
+ redisClient: RedisClientType
60
+ ) {
61
+ super(drive);
62
+ this.redisClient = redisClient;
63
+ }
64
+
65
+ async getTransmitter(
66
+ driveId: string,
67
+ listenerId: string
68
+ ): Promise<ITransmitter | undefined> {
69
+ const { listener } = await this.getListener(driveId, listenerId);
70
+ let transmitter: ITransmitter | undefined;
71
+
72
+ switch (listener.callInfo?.transmitterType) {
73
+ case 'SwitchboardPush': {
74
+ transmitter = new SwitchboardPushTransmitter(
75
+ listener,
76
+ this.drive
77
+ );
78
+ break;
79
+ }
80
+
81
+ case 'PullResponder': {
82
+ transmitter = new PullResponderTransmitter(
83
+ listener,
84
+ this.drive,
85
+ this
86
+ );
87
+ break;
88
+ }
89
+ case 'Internal': {
90
+ transmitter = new InternalTransmitter(listener, this.drive);
91
+ break;
92
+ }
93
+ }
94
+
95
+ if (!transmitter) {
96
+ throw new Error('Transmitter not found');
97
+ }
98
+
99
+ return transmitter;
100
+ }
101
+
102
+ async addListener(listener: Listener) {
103
+ const { driveId, listenerId } = listener;
104
+ await this.redisClient.hSet(driveId, listenerId, JSON.stringify({
105
+ block: listener.block,
106
+ driveId: listener.driveId,
107
+ pendingTimeout: '0',
108
+ listener,
109
+ listenerStatus: 'CREATED',
110
+ syncUnits: new Map()
111
+ }));
112
+
113
+ const transmitter = await this.getTransmitter(driveId, listenerId);
114
+ if (!transmitter) {
115
+ throw new Error('Transmitter not found');
116
+ }
117
+ return transmitter;
118
+ }
119
+
120
+ async updateListener(listenerState: ListenerState) {
121
+ const { driveId, listenerId, block } = listenerState.listener;
122
+ await this.redisClient.hSet(driveId, listenerId, JSON.stringify({
123
+ block: block,
124
+ driveId: driveId,
125
+ pendingTimeout: '0',
126
+ listener: listenerState.listener,
127
+ listenerStatus: 'CREATED',
128
+ syncUnits: listenerState.syncUnits
129
+ }));
130
+ }
131
+
132
+ async removeListener(driveId: string, listenerId: string) {
133
+ return (await this.redisClient.hDel(driveId, listenerId)) > 0;
134
+ }
135
+
136
+ async removeSyncUnits(
137
+ driveId: string,
138
+ syncUnits: Pick<SynchronizationUnit, 'syncId'>[]
139
+ ) {
140
+ const listeners = await this.getListeners(driveId)
141
+ await Promise.all(listeners.map((listener) => {
142
+ for (const syncUnit of syncUnits) {
143
+ listener.syncUnits.delete(syncUnit.syncId);
144
+ }
145
+
146
+ return this.updateListener(listener);
147
+
148
+ }))
149
+
150
+ return;
151
+ }
152
+
153
+ async updateSynchronizationRevisions(
154
+ driveId: string,
155
+ syncUnits: SynchronizationUnit[],
156
+ source: StrandUpdateSource,
157
+ willUpdate?: (listeners: Listener[]) => void,
158
+ onError?: (
159
+ error: Error,
160
+ driveId: string,
161
+ listener: ListenerState
162
+ ) => void,
163
+ forceSync = false
164
+ ) {
165
+ const listeners = await this.getListeners(driveId);
166
+ const outdatedListeners: Listener[] = [];
167
+ for (const listener of listeners) {
168
+ if (
169
+ outdatedListeners.find(
170
+ l => l.listenerId === listener.listener.listenerId
171
+ )
172
+ ) {
173
+ continue;
174
+ }
175
+ for (const syncUnit of syncUnits) {
176
+ if (!this._checkFilter(listener.listener.filter, syncUnit)) {
177
+ continue;
178
+ }
179
+
180
+ const listenerRev = listener.syncUnits.get(syncUnit.syncId);
181
+
182
+ if (
183
+ !listenerRev ||
184
+ listenerRev.listenerRev < syncUnit.revision
185
+ ) {
186
+ outdatedListeners.push(listener.listener);
187
+ break;
188
+ }
189
+ }
190
+ }
191
+
192
+ if (outdatedListeners.length) {
193
+ willUpdate?.(outdatedListeners);
194
+ return this.triggerUpdate(forceSync, source, onError);
195
+ }
196
+ return [];
197
+ }
198
+
199
+ async updateListenerRevision(
200
+ listenerId: string,
201
+ driveId: string,
202
+ syncId: string,
203
+ listenerRev: number
204
+ ): Promise<void> {
205
+ const listener = await this.getListener(driveId, listenerId);
206
+ const lastUpdated = new Date().toISOString();
207
+ const entry = listener.syncUnits.get(syncId);
208
+ if (entry) {
209
+ entry.listenerRev = listenerRev;
210
+ entry.lastUpdated = lastUpdated;
211
+ } else {
212
+ listener.syncUnits.set(syncId, { listenerRev, lastUpdated });
213
+ }
214
+
215
+ await this.updateListener(listener);
216
+
217
+ return Promise.resolve();
218
+ }
219
+
220
+ triggerUpdate = debounce(
221
+ this.__triggerUpdate.bind(this),
222
+ ListenerManager.LISTENER_UPDATE_DELAY
223
+ );
224
+
225
+ private async __triggerUpdate(
226
+ source: StrandUpdateSource,
227
+ onError?: (
228
+ error: Error,
229
+ driveId: string,
230
+ listener: ListenerState
231
+ ) => void
232
+ ) {
233
+ const listenerUpdates: ListenerUpdate[] = [];
234
+ for (const [driveId, drive] of this.listenerState) {
235
+ for (const [id, listener] of drive) {
236
+ const transmitter = await this.getTransmitter(driveId, id);
237
+ if (!transmitter) {
238
+ continue;
239
+ }
240
+
241
+ const syncUnits = await this.getListenerSyncUnits(
242
+ driveId,
243
+ listener.listener.listenerId
244
+ );
245
+
246
+ const strandUpdates: StrandUpdate[] = [];
247
+ for (const syncUnit of syncUnits) {
248
+ const unitState = listener.syncUnits.get(syncUnit.syncId);
249
+
250
+
251
+
252
+ if (
253
+ unitState &&
254
+ unitState.listenerRev >= syncUnit.revision
255
+ ) {
256
+ continue;
257
+ }
258
+
259
+ const opData: OperationUpdate[] = [];
260
+ try {
261
+ const data = await this.drive.getOperationData(
262
+ // TODO - join queries, DEAL WITH INVALID SYNC ID ERROR
263
+ driveId,
264
+ syncUnit.syncId,
265
+ {
266
+ fromRevision: unitState?.listenerRev
267
+ }
268
+ );
269
+ opData.push(...data);
270
+ } catch (e) {
271
+ logger.error(e);
272
+ }
273
+
274
+ if (!opData.length) {
275
+ continue;
276
+ }
277
+
278
+ strandUpdates.push({
279
+ driveId,
280
+ documentId: syncUnit.documentId,
281
+ branch: syncUnit.branch,
282
+ operations: opData,
283
+ scope: syncUnit.scope as OperationScope
284
+ });
285
+ }
286
+
287
+ if (strandUpdates.length == 0) {
288
+ continue;
289
+ }
290
+
291
+ listener.pendingTimeout = new Date(
292
+ new Date().getTime() / 1000 + 300
293
+ ).toISOString();
294
+ listener.listenerStatus = 'PENDING';
295
+
296
+ // TODO update listeners in parallel, blocking for listeners with block=true
297
+ try {
298
+ const listenerRevisions = await transmitter?.transmit(
299
+ strandUpdates,
300
+ source
301
+ );
302
+
303
+ listener.pendingTimeout = '0';
304
+ listener.listenerStatus = 'PENDING';
305
+
306
+ const lastUpdated = new Date().toISOString();
307
+
308
+ for (const revision of listenerRevisions) {
309
+ const syncUnit = syncUnits.find(
310
+ unit =>
311
+ revision.documentId === unit.documentId &&
312
+ revision.scope === unit.scope &&
313
+ revision.branch === unit.branch
314
+ );
315
+ if (syncUnit) {
316
+ listener.syncUnits.set(syncUnit.syncId, {
317
+ lastUpdated,
318
+ listenerRev: revision.revision
319
+ });
320
+ } else {
321
+ logger.warn(
322
+ `Received revision for untracked unit for listener ${listener.listener.listenerId}`,
323
+ revision
324
+ );
325
+ }
326
+ }
327
+ const revisionError = listenerRevisions.find(
328
+ l => l.status !== 'SUCCESS'
329
+ );
330
+ if (revisionError) {
331
+ throw new OperationError(
332
+ revisionError.status as ErrorStatus,
333
+ undefined,
334
+ revisionError.error,
335
+ revisionError.error
336
+ );
337
+ }
338
+ listener.listenerStatus = 'SUCCESS';
339
+ listenerUpdates.push({
340
+ listenerId: listener.listener.listenerId,
341
+ listenerRevisions
342
+ });
343
+ } catch (e) {
344
+ // TODO: Handle error based on listener params (blocking, retry, etc)
345
+ onError?.(e as Error, driveId, listener);
346
+ listener.listenerStatus =
347
+ e instanceof OperationError ? e.status : 'ERROR';
348
+ }
349
+ }
350
+ }
351
+ return listenerUpdates;
352
+ }
353
+
354
+ async getListenerSyncUnits(driveId: string, listenerId: string) {
355
+ const listener = await this.getListener(driveId, listenerId)
356
+ const { filter } = listener.listener;
357
+ return this.drive.getSynchronizationUnits(
358
+ driveId,
359
+ filter.documentId ?? ['*'],
360
+ filter.scope ?? ['*'],
361
+ filter.branch ?? ['*'],
362
+ filter.documentType ?? ['*']
363
+ );
364
+ }
365
+
366
+ async getListenerSyncUnitIds(driveId: string, listenerId: string) {
367
+ const listener = await this.getListener(driveId, listenerId)
368
+ const { filter } = listener.listener;
369
+ return this.drive.getSynchronizationUnitsIds(
370
+ driveId,
371
+ filter.documentId ?? ['*'],
372
+ filter.scope ?? ['*'],
373
+ filter.branch ?? ['*'],
374
+ filter.documentType ?? ['*']
375
+ );
376
+ }
377
+
378
+ async initDrive(drive: DocumentDriveDocument) {
379
+ const {
380
+ state: {
381
+ local: { listeners }
382
+ }
383
+ } = drive;
384
+
385
+ for (const listener of listeners) {
386
+ await this.addListener({
387
+ block: listener.block,
388
+ driveId: drive.state.global.id,
389
+ filter: {
390
+ branch: listener.filter.branch ?? [],
391
+ documentId: listener.filter.documentId ?? [],
392
+ documentType: listener.filter.documentType,
393
+ scope: listener.filter.scope ?? []
394
+ },
395
+ listenerId: listener.listenerId,
396
+ system: listener.system,
397
+ callInfo: listener.callInfo ?? undefined,
398
+ label: listener.label ?? ''
399
+ });
400
+ }
401
+ }
402
+
403
+ async removeDrive(driveId: string): Promise<void> {
404
+ await this.redisClient.del(driveId);
405
+ }
406
+
407
+ async getListener(driveId: string, listenerId: string): Promise<ListenerState> {
408
+ const result = await this.redisClient.hGet(driveId, listenerId);
409
+ if (!result) throw new Error('Listener not found');
410
+ return JSON.parse(result);
411
+ }
412
+
413
+ async getListeners(driveId: string): Promise<ListenerState[]> {
414
+ const result = await this.redisClient.hGetAll(driveId);
415
+ if (!result) throw new Error('Listener not found');
416
+ const listenerIds = Object.keys(result);
417
+ return listenerIds.map(id => JSON.parse(result[id]!));
418
+ }
419
+
420
+ async getStrands(
421
+ driveId: string,
422
+ listenerId: string,
423
+ since?: string
424
+ ): Promise<StrandUpdate[]> {
425
+ // fetch listenerState from listenerManager
426
+ const listener = await this.getListener(driveId, listenerId);
427
+
428
+ // fetch operations from drive and prepare strands
429
+ const strands: StrandUpdate[] = [];
430
+
431
+ const syncUnits = await this.getListenerSyncUnits(driveId, listenerId);
432
+
433
+ for (const syncUnit of syncUnits) {
434
+ if (syncUnit.revision < 0) {
435
+ continue;
436
+ }
437
+ const entry = listener.syncUnits.get(syncUnit.syncId);
438
+ if (entry && entry.listenerRev >= syncUnit.revision) {
439
+ continue;
440
+ }
441
+
442
+ const { documentId, driveId, scope, branch } = syncUnit;
443
+ try {
444
+ const operations = await this.drive.getOperationData(
445
+ // DEAL WITH INVALID SYNC ID ERROR
446
+ driveId,
447
+ syncUnit.syncId,
448
+ {
449
+ since,
450
+ fromRevision: entry?.listenerRev
451
+ }
452
+ );
453
+
454
+ if (!operations.length) {
455
+ continue;
456
+ }
457
+
458
+ strands.push({
459
+ driveId,
460
+ documentId,
461
+ scope: scope as OperationScope,
462
+ branch,
463
+ operations
464
+ });
465
+ } catch (error) {
466
+ logger.error(error);
467
+ continue;
468
+ }
469
+ }
470
+
471
+ return strands;
472
+ }
473
+ }
@@ -7,6 +7,7 @@ import { logger as defaultLogger } from '../../../utils/logger';
7
7
  import { OperationError } from '../../error';
8
8
  import {
9
9
  BaseDocumentDriveServer,
10
+ BaseListenerManager,
10
11
  IOperationResult,
11
12
  Listener,
12
13
  ListenerRevision,
@@ -47,12 +48,12 @@ export interface IPullResponderTransmitter extends ITransmitter {
47
48
  export class PullResponderTransmitter implements IPullResponderTransmitter {
48
49
  private drive: BaseDocumentDriveServer;
49
50
  private listener: Listener;
50
- private manager: ListenerManager;
51
+ private manager: BaseListenerManager;
51
52
 
52
53
  constructor(
53
54
  listener: Listener,
54
55
  drive: BaseDocumentDriveServer,
55
- manager: ListenerManager
56
+ manager: BaseListenerManager
56
57
  ) {
57
58
  this.listener = listener;
58
59
  this.drive = drive;
@@ -341,6 +341,12 @@ export abstract class BaseDocumentDriveServer {
341
341
  abstract clearStorage(): Promise<void>;
342
342
  }
343
343
 
344
+ export abstract class IListenerState {
345
+ abstract setListener(listener: ListenerState): Promise<void>;
346
+ abstract getListener(driveId: string, listenerId: string): Promise<ListenerState>;
347
+ abstract removeListener(driveId: string, listenerId: string): Promise<void>;
348
+ }
349
+
344
350
  export abstract class BaseListenerManager {
345
351
  protected drive: BaseDocumentDriveServer;
346
352
  protected listenerState = new Map<string, Map<string, ListenerState>>();
@@ -1,364 +0,0 @@
1
- import { Client, createClient } from 'graphql-ws';
2
- import WebSocket from 'isomorphic-ws';
3
- import { logger } from '../../../utils/logger';
4
- import {
5
- IOperationResult,
6
- Listener,
7
- ListenerRevision,
8
- ListenerRevisionWithError,
9
- RemoteDriveOptions,
10
- StrandUpdate
11
- } from '../../types';
12
- import { ListenerManager } from '../manager';
13
- import { ITriggerTransmitter, SubscriptionTrigger } from './types';
14
- import { ListenerFilter, Trigger, z } from 'document-model-libs/document-drive';
15
- import { gql, requestGraphql } from '../../../utils/graphql';
16
- import { generateUUID } from '../../../utils';
17
- import { OperationScope } from 'document-model/document';
18
- import { OperationError } from '../../error';
19
- import { StrandUpdateGraphQL } from './pull-responder';
20
-
21
-
22
- export class SubscriptionPushTransmitter implements ITriggerTransmitter {
23
- private _strands: StrandUpdate[] = [];
24
- private listener: Listener;
25
- private manager: ListenerManager;
26
- private _init: Promise<StrandUpdate[]> | null = null;
27
- private handler: ((strands: StrandUpdate[]) => void) | null = null;
28
-
29
- constructor(
30
- listener: Listener,
31
- manager: ListenerManager,
32
- ) {
33
- this.listener = listener;
34
- this.manager = manager;
35
- }
36
-
37
- async init() {
38
- if (this._init) {
39
- return this._init;
40
- }
41
- this._init = this.#refreshStrands();
42
- return this._init;
43
- }
44
-
45
- #updateStrands(strands: StrandUpdate[]): void {
46
- this._strands = strands;
47
- this.handler?.(strands);
48
- }
49
-
50
- async #refreshStrands() {
51
- const strands = await this.manager.getStrands(
52
- this.listener.driveId,
53
- this.listener.listenerId
54
- );
55
- this.#updateStrands(strands);
56
- return Promise.resolve(strands);
57
- }
58
-
59
- async * strandsGenerator(since?: number): AsyncGenerator<StrandUpdate[]> {
60
- await this.init();
61
- let waitHandler = null;
62
- let firstTime = true;
63
- // eslint-disable-next-line
64
- while (true) {
65
- if (waitHandler) {
66
- await waitHandler;
67
- }
68
- waitHandler = new Promise<StrandUpdate[]>(resolve => {
69
- this.handler = resolve;
70
- });
71
-
72
- // only return empty array on first call
73
- if (this._strands.length || firstTime) {
74
- firstTime = false;
75
- // TODO add support for 'since' parameter
76
- yield this._strands.slice();
77
- }
78
- }
79
- }
80
-
81
- async transmit(strands: StrandUpdate[]): Promise<ListenerRevision[]> {
82
- // if subscription has not been initiated by
83
- // the client then ignores new strands
84
- if (!this._init) {
85
- return [];
86
- }
87
- console.log("TRANSMIT", this.listener.listenerId, strands.map(s => s.operations.length))
88
- this.#updateStrands([...this._strands, ...strands]);
89
- return Promise.resolve([]);
90
- }
91
-
92
- async processAcknowledge(
93
- driveId: string,
94
- listenerId: string,
95
- revisions: ListenerRevision[]
96
- ): Promise<boolean> {
97
- const syncUnits = await this.manager.getListenerSyncUnits(
98
- driveId,
99
- listenerId
100
- );
101
- let success = true;
102
- let acknowledged = false;
103
- for (const revision of revisions) {
104
- const syncUnit = syncUnits.find(
105
- s =>
106
- s.scope === revision.scope &&
107
- s.branch === revision.branch &&
108
- s.driveId === revision.driveId &&
109
- s.documentId == revision.documentId
110
- );
111
- if (!syncUnit) {
112
- logger.warn(
113
- 'Unknown sync unit was acknowledged',
114
- revision
115
- );
116
- success = false;
117
- continue;
118
- }
119
-
120
- await this.manager.updateListenerRevision(
121
- listenerId,
122
- driveId,
123
- syncUnit.syncId,
124
- revision.revision
125
- );
126
- acknowledged = true;
127
- }
128
- if (acknowledged) {
129
- await this.#refreshStrands();
130
- }
131
-
132
- return success;
133
- }
134
-
135
- static async registerSubscription(
136
- driveId: string,
137
- url: string,
138
- filter: ListenerFilter
139
- ): Promise<Listener['listenerId']> {
140
- // graphql request to switchboard
141
- const { registerListener } = await requestGraphql<{
142
- registerListener: {
143
- listenerId: Listener['listenerId'];
144
- };
145
- }>(
146
- url,
147
- gql`
148
- mutation registerListener(
149
- $filter: InputListenerFilter!
150
- $type: TransmitterType!
151
- ) {
152
- registerListener(filter: $filter, type: $type) {
153
- listenerId
154
- }
155
- }
156
- `,
157
- { filter, type: 'Subscription' }
158
- );
159
- return registerListener.listenerId;
160
- }
161
-
162
- static async createTrigger(
163
- driveId: string,
164
- url: string,
165
- options: Pick<RemoteDriveOptions, 'pullFilter'>
166
- ): Promise<Trigger> {
167
- const { pullFilter } = options;
168
- const listenerId = await SubscriptionTransmitter.registerSubscription(
169
- driveId,
170
- url,
171
- pullFilter ?? {
172
- documentId: ['*'],
173
- documentType: ['*'],
174
- branch: ['*'],
175
- scope: ['*']
176
- }
177
- );
178
-
179
- const trigger: Trigger = {
180
- id: generateUUID(),
181
- type: 'Subscription',
182
- data: {
183
- url,
184
- listenerId,
185
- }
186
- };
187
- return trigger;
188
- }
189
-
190
- static isTrigger(
191
- trigger: Trigger
192
- ): trigger is SubscriptionTrigger {
193
- return (
194
- trigger.type === 'Subscription' &&
195
- z.SubscriptionTriggerDataSchema().safeParse(trigger.data).success
196
- );
197
- }
198
-
199
- static setup(driveId: string,
200
- trigger: SubscriptionTrigger,
201
- onStrandUpdate: (strand: StrandUpdate) => Promise<IOperationResult>,
202
- onError: (error: Error) => void,
203
- onRevisions?: (revisions: ListenerRevisionWithError[]) => void,
204
- onAcknowledge?: (success: boolean) => void) {
205
-
206
- const { url } = trigger.data;
207
- let subscriptionUrl = url.replace("http", "ws")
208
- subscriptionUrl += subscriptionUrl.endsWith("/") ? "ws" : "/ws";
209
-
210
- const client = createClient({
211
- url: subscriptionUrl,
212
- webSocketImpl: WebSocket,
213
- });
214
- try {
215
- SubscriptionTransmitter.subscribeStrands(client, trigger, onStrandUpdate, onError, onRevisions, onAcknowledge).catch(onError);
216
- } catch (error) {
217
- onError(error as Error);
218
- }
219
- return () => { return client.dispose(); };
220
- }
221
-
222
- private static async subscribeStrands(client: Client, trigger: SubscriptionTrigger,
223
- onStrandUpdate: (strand: StrandUpdate) => Promise<IOperationResult>,
224
- onError: (error: Error) => void,
225
- onRevisions?: (revisions: ListenerRevisionWithError[]) => void,
226
- onAcknowledge?: (success: boolean) => void) {
227
- const { listenerId } = trigger.data;
228
- const subscription = client.iterate<{ subscribeStrands: StrandUpdateGraphQL[] }>({
229
- query: `
230
- subscription($listenerId: ID) {
231
- subscribeStrands(listenerId: $listenerId) {
232
- branch
233
- documentId
234
- driveId
235
- operations {
236
- timestamp
237
- skip
238
- type
239
- input
240
- hash
241
- index
242
- context {
243
- signer {
244
- user {
245
- address
246
- networkId
247
- chainId
248
- }
249
- app {
250
- name
251
- key
252
- }
253
- signature
254
- }
255
- }
256
- }
257
- scope
258
- }
259
- }`,
260
- variables: {
261
- listenerId,
262
- }
263
- });
264
- for await (const { errors, data } of subscription) {
265
- const error = errors?.at(0);
266
- if (error) {
267
- onError(error);
268
- } else {
269
- const strands = data?.subscribeStrands ?? [];
270
- console.log(listenerId, "Save strands", strands);
271
- SubscriptionTransmitter.saveStrands(trigger, client, strands, onStrandUpdate, onError, onRevisions, onAcknowledge).catch(onError);
272
- }
273
- }
274
- }
275
-
276
- static async saveStrands(
277
- trigger: SubscriptionTrigger,
278
- client: Client,
279
- strandsQL: StrandUpdateGraphQL[],
280
- onStrandUpdate: (strand: StrandUpdate) => Promise<IOperationResult>,
281
- onError: (error: Error) => void,
282
- onRevisions?: (revisions: ListenerRevisionWithError[]) => void,
283
- onAcknowledge?: (success: boolean) => void) {
284
- const strands = strandsQL.map(s => ({
285
- ...s,
286
- operations: s.operations.map(o => ({
287
- ...o,
288
- scope: s.scope,
289
- branch: s.branch,
290
- input: JSON.parse(o.input) as object
291
- }))
292
- }));
293
- // if there are no new strands then do nothing
294
- if (!strands.length) {
295
- onRevisions?.([]);
296
- return;
297
- }
298
-
299
- const listenerRevisions: ListenerRevisionWithError[] = [];
300
-
301
-
302
- for (const strand of strands) {
303
- let error: Error | undefined = undefined;
304
- let result: IOperationResult | undefined = undefined;
305
- try {
306
- result = await onStrandUpdate(strand);
307
- if (result.error) {
308
- throw result.error;
309
- }
310
- } catch (e) {
311
- error = e as Error;
312
- onError(error);
313
- }
314
-
315
- listenerRevisions.push({
316
- branch: strand.branch,
317
- documentId: strand.documentId || '',
318
- driveId: strand.driveId,
319
- revision: result?.document?.operations[strand.scope]?.at(-1)?.index ?? -1,
320
- scope: strand.scope as OperationScope,
321
- status: error
322
- ? error instanceof OperationError
323
- ? error.status
324
- : 'ERROR'
325
- : 'SUCCESS',
326
- error
327
- });
328
- }
329
-
330
- onRevisions?.(listenerRevisions);
331
-
332
- await SubscriptionTransmitter.acknowledgeStrands(
333
- client,
334
- trigger.data.listenerId,
335
- listenerRevisions.map(revision => {
336
- const { error, ...rest } = revision;
337
- return rest;
338
- })
339
- )
340
- .then(result => onAcknowledge?.(result))
341
- .catch(error => logger.error('ACK error', error));
342
- }
343
-
344
- static async acknowledgeStrands(
345
- client: Client,
346
- listenerId: string,
347
- revisions: ListenerRevision[]
348
- ): Promise<boolean> {
349
- const subscription = client.iterate<{ acknowledge: boolean }>({
350
- query: `
351
- mutation acknowledge(
352
- $listenerId: String!
353
- $revisions: [ListenerRevisionInput]
354
- ) {
355
- acknowledge(listenerId: $listenerId, revisions: $revisions)
356
- }
357
- `,
358
- variables: { listenerId, revisions }
359
- });
360
- const result = await subscription.next();
361
-
362
- return (result.value as { acknowledge: boolean }).acknowledge as boolean;
363
- }
364
- }