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 +7 -0
- package/package.json +1 -1
- package/src/server/index.ts +63 -58
- package/src/server/listener/redis-manager.ts +473 -0
- package/src/server/listener/transmitter/pull-responder.ts +3 -2
- package/src/server/types.ts +6 -0
- package/src/server/listener/transmitter/subscription-push.ts +0 -364
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
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
157
|
+
strand.driveId,
|
|
158
|
+
operations as Operation<DocumentDriveAction | BaseAction>[],
|
|
159
|
+
{ source }
|
|
160
|
+
)
|
|
156
161
|
: this.queueOperations(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
|
|
944
|
-
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1000
|
+
drive,
|
|
1001
|
+
id,
|
|
1002
|
+
lastRemainingOperation.index,
|
|
1003
|
+
lastRemainingOperation.scope,
|
|
1004
|
+
'main'
|
|
1005
|
+
)
|
|
1001
1006
|
: this.storage.getDriveOperationResultingState?.(
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
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:
|
|
51
|
+
private manager: BaseListenerManager;
|
|
51
52
|
|
|
52
53
|
constructor(
|
|
53
54
|
listener: Listener,
|
|
54
55
|
drive: BaseDocumentDriveServer,
|
|
55
|
-
manager:
|
|
56
|
+
manager: BaseListenerManager
|
|
56
57
|
) {
|
|
57
58
|
this.listener = listener;
|
|
58
59
|
this.drive = drive;
|
package/src/server/types.ts
CHANGED
|
@@ -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
|
-
}
|