document-drive 1.0.0-experimental.8 → 1.0.0-websockets

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.
@@ -14,7 +14,7 @@ import {
14
14
  StrandUpdate,
15
15
  SynchronizationUnit
16
16
  } from '../types';
17
- import { PullResponderTransmitter } from './transmitter';
17
+ import { PullResponderTransmitter, SubscriptionTransmitter } from './transmitter';
18
18
  import { InternalTransmitter } from './transmitter/internal';
19
19
  import { SwitchboardPushTransmitter } from './transmitter/switchboard-push';
20
20
  import { ITransmitter } from './transmitter/types';
@@ -53,7 +53,7 @@ export class ListenerManager extends BaseListenerManager {
53
53
  driveId: string,
54
54
  listenerId: string
55
55
  ): Promise<ITransmitter | undefined> {
56
- return this.transmitters[driveId]?.[listenerId];
56
+ return Promise.resolve(this.transmitters[driveId]?.[listenerId]);
57
57
  }
58
58
 
59
59
  async addListener(listener: Listener) {
@@ -93,6 +93,10 @@ export class ListenerManager extends BaseListenerManager {
93
93
  );
94
94
  break;
95
95
  }
96
+ case "Subscription": {
97
+ transmitter = new SubscriptionTransmitter(listener, this);
98
+ break;
99
+ }
96
100
  case 'Internal': {
97
101
  transmitter = new InternalTransmitter(listener, this.drive);
98
102
  break;
@@ -106,7 +110,7 @@ export class ListenerManager extends BaseListenerManager {
106
110
  const driveTransmitters = this.transmitters[drive] || {};
107
111
  driveTransmitters[listener.listenerId] = transmitter;
108
112
  this.transmitters[drive] = driveTransmitters;
109
- return transmitter;
113
+ return Promise.resolve(transmitter);
110
114
  }
111
115
 
112
116
  async removeListener(driveId: string, listenerId: string) {
@@ -115,10 +119,10 @@ export class ListenerManager extends BaseListenerManager {
115
119
  return false;
116
120
  }
117
121
 
118
- return driveMap.delete(listenerId);
122
+ return Promise.resolve(driveMap.delete(listenerId));
119
123
  }
120
124
 
121
- async removeSyncUnits(driveId: string, syncUnits: SynchronizationUnit[]) {
125
+ async removeSyncUnits(driveId: string, syncUnits: Pick<SynchronizationUnit, "syncId">[]) {
122
126
  const listeners = this.listenerState.get(driveId);
123
127
  if (!listeners) {
124
128
  return;
@@ -128,6 +132,7 @@ export class ListenerManager extends BaseListenerManager {
128
132
  listener.syncUnits.delete(syncUnit.syncId);
129
133
  }
130
134
  }
135
+ return Promise.resolve();
131
136
  }
132
137
 
133
138
  async updateSynchronizationRevisions(
@@ -203,6 +208,8 @@ export class ListenerManager extends BaseListenerManager {
203
208
  } else {
204
209
  listener.syncUnits.set(syncId, { listenerRev, lastUpdated });
205
210
  }
211
+
212
+ return Promise.resolve();
206
213
  }
207
214
 
208
215
  triggerUpdate = debounce(
@@ -243,7 +250,7 @@ export class ListenerManager extends BaseListenerManager {
243
250
 
244
251
  const opData: OperationUpdate[] = [];
245
252
  try {
246
- const data = await this.drive.getOperationData(
253
+ const data = await this.drive.getOperationData( // TODO - join queries, DEAL WITH INVALID SYNC ID ERROR
247
254
  driveId,
248
255
  syncUnit.syncId,
249
256
  {
@@ -373,6 +380,21 @@ export class ListenerManager extends BaseListenerManager {
373
380
  );
374
381
  }
375
382
 
383
+ getListenerSyncUnitIds(driveId: string, listenerId: string) {
384
+ const listener = this.listenerState.get(driveId)?.get(listenerId);
385
+ if (!listener) {
386
+ return [];
387
+ }
388
+ const filter = listener.listener.filter;
389
+ return this.drive.getSynchronizationUnitsIds(
390
+ driveId,
391
+ filter.documentId ?? ['*'],
392
+ filter.scope ?? ['*'],
393
+ filter.branch ?? ['*'],
394
+ filter.documentType ?? ['*']
395
+ );
396
+ }
397
+
376
398
  async initDrive(drive: DocumentDriveDocument) {
377
399
  const {
378
400
  state: {
@@ -398,6 +420,14 @@ export class ListenerManager extends BaseListenerManager {
398
420
  }
399
421
  }
400
422
 
423
+ async removeDrive(driveId: string): Promise<void> {
424
+ this.listenerState.delete(driveId);
425
+ const transmitters = this.transmitters[driveId];
426
+ if (transmitters) {
427
+ await Promise.all(Object.values(transmitters).map(t => t.disconnect?.()));
428
+ }
429
+ }
430
+
401
431
  getListener(driveId: string, listenerId: string): Promise<ListenerState> {
402
432
  const drive = this.listenerState.get(driveId);
403
433
  if (!drive) throw new Error('Drive not found');
@@ -420,32 +450,40 @@ export class ListenerManager extends BaseListenerManager {
420
450
  const syncUnits = await this.getListenerSyncUnits(driveId, listenerId);
421
451
 
422
452
  for (const syncUnit of syncUnits) {
453
+ if (syncUnit.revision < 0) {
454
+ continue;
455
+ }
423
456
  const entry = listener.syncUnits.get(syncUnit.syncId);
424
457
  if (entry && entry.listenerRev >= syncUnit.revision) {
425
458
  continue;
426
459
  }
427
460
 
428
461
  const { documentId, driveId, scope, branch } = syncUnit;
429
- const operations = await this.drive.getOperationData(
430
- driveId,
431
- syncUnit.syncId,
432
- {
433
- since,
434
- fromRevision: entry?.listenerRev
462
+ try {
463
+ const operations = await this.drive.getOperationData( // DEAL WITH INVALID SYNC ID ERROR
464
+ driveId,
465
+ syncUnit.syncId,
466
+ {
467
+ since,
468
+ fromRevision: entry?.listenerRev
469
+ }
470
+ );
471
+
472
+ if (!operations.length) {
473
+ continue;
435
474
  }
436
- );
437
475
 
438
- if (!operations.length) {
476
+ strands.push({
477
+ driveId,
478
+ documentId,
479
+ scope: scope as OperationScope,
480
+ branch,
481
+ operations
482
+ });
483
+ } catch (error) {
484
+ logger.error(error);
439
485
  continue;
440
486
  }
441
-
442
- strands.push({
443
- driveId,
444
- documentId,
445
- scope: scope as OperationScope,
446
- branch,
447
- operations
448
- });
449
487
  }
450
488
 
451
489
  return strands;
@@ -2,3 +2,4 @@ export * from './pull-responder';
2
2
  export * from './switchboard-push';
3
3
  export * from './internal';
4
4
  export * from './types';
5
+ export * from './subscription';
@@ -12,6 +12,7 @@ import { logger } from '../../../utils/logger';
12
12
 
13
13
  export interface IReceiver {
14
14
  transmit: (strands: InternalTransmitterUpdate[]) => Promise<void>;
15
+ disconnect: () => Promise<void>;
15
16
  }
16
17
 
17
18
  export type InternalTransmitterUpdate<
@@ -88,4 +89,8 @@ export class InternalTransmitter implements ITransmitter {
88
89
  setReceiver(receiver: IReceiver) {
89
90
  this.receiver = receiver;
90
91
  }
92
+
93
+ async disconnect(): Promise<void> {
94
+ await this.receiver?.disconnect();
95
+ }
91
96
  }
@@ -1,5 +1,5 @@
1
- import { ListenerFilter, Trigger, z } from 'document-model-libs/document-drive';
2
- import { Operation, OperationScope } from 'document-model/document';
1
+ import { ListenerFilter, Trigger } from 'document-model-libs/document-drive';
2
+ import { OperationScope } from 'document-model/document';
3
3
  import { PULL_DRIVE_INTERVAL } from '../..';
4
4
  import { generateUUID } from '../../../utils';
5
5
  import { gql, requestGraphql } from '../../../utils/graphql';
@@ -16,7 +16,7 @@ import {
16
16
  StrandUpdate
17
17
  } from '../../types';
18
18
  import { ListenerManager } from '../manager';
19
- import { ITransmitter, PullResponderTrigger } from './types';
19
+ import { ITriggerTransmitter, PullResponderTrigger } from './types';
20
20
 
21
21
  export type OperationUpdateGraphQL = Omit<OperationUpdate, 'input'> & {
22
22
  input: string;
@@ -36,7 +36,7 @@ export type StrandUpdateGraphQL = Omit<StrandUpdate, 'operations'> & {
36
36
  operations: OperationUpdateGraphQL[];
37
37
  };
38
38
 
39
- export interface IPullResponderTransmitter extends ITransmitter {
39
+ export interface IPullResponderTransmitter extends ITriggerTransmitter {
40
40
  getStrands(since?: string): Promise<StrandUpdate[]>;
41
41
  }
42
42
 
@@ -72,7 +72,7 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
72
72
  listenerId: string,
73
73
  revisions: ListenerRevision[]
74
74
  ): Promise<boolean> {
75
- const syncUnits = await this.manager.getListenerSyncUnits(
75
+ const syncUnits = await this.manager.getListenerSyncUnitIds(
76
76
  driveId,
77
77
  listenerId
78
78
  );
@@ -153,6 +153,7 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
153
153
  scope
154
154
  branch
155
155
  operations {
156
+ id
156
157
  timestamp
157
158
  skip
158
159
  type
@@ -237,17 +238,10 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
237
238
  const listenerRevisions: ListenerRevisionWithError[] = [];
238
239
 
239
240
  for (const strand of strands) {
240
- const operations: Operation[] = strand.operations.map(
241
- (op) => ({
242
- ...op,
243
- scope: strand.scope,
244
- branch: strand.branch
245
- })
246
- );
247
-
241
+ let result: IOperationResult | undefined = undefined;
248
242
  let error: Error | undefined = undefined;
249
243
  try {
250
- const result = await onStrandUpdate(strand);
244
+ result = await onStrandUpdate(strand);
251
245
  if (result.error) {
252
246
  throw result.error;
253
247
  }
@@ -260,7 +254,9 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
260
254
  branch: strand.branch,
261
255
  documentId: strand.documentId || '',
262
256
  driveId: strand.driveId,
263
- revision: operations.pop()?.index ?? -1,
257
+ revision:
258
+ result?.document?.operations[strand.scope]?.at(-1)
259
+ ?.index ?? -1,
264
260
  scope: strand.scope as OperationScope,
265
261
  status: error
266
262
  ? error instanceof OperationError
@@ -0,0 +1,364 @@
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 SubscriptionTransmitter 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
+ }
@@ -1,11 +1,21 @@
1
1
  import {
2
2
  PullResponderTriggerData,
3
+ SubscriptionTriggerData,
3
4
  Trigger
4
5
  } from 'document-model-libs/document-drive';
5
6
  import { ListenerRevision, StrandUpdate } from '../..';
6
7
 
7
8
  export interface ITransmitter {
8
9
  transmit(strands: StrandUpdate[]): Promise<ListenerRevision[]>;
10
+ disconnect?(): Promise<void>;
11
+ }
12
+
13
+ export interface ITriggerTransmitter extends ITransmitter {
14
+ processAcknowledge(
15
+ driveId: string,
16
+ listenerId: string,
17
+ revisions: ListenerRevision[]
18
+ ): Promise<boolean>
9
19
  }
10
20
 
11
21
  export interface InternalTransmitterService extends ITransmitter {
@@ -16,3 +26,8 @@ export type PullResponderTrigger = Omit<Trigger, 'data' | 'type'> & {
16
26
  data: PullResponderTriggerData;
17
27
  type: 'PullResponder';
18
28
  };
29
+
30
+ export type SubscriptionTrigger = Omit<Trigger, 'data' | 'type'> & {
31
+ data: SubscriptionTriggerData;
32
+ type: 'Subscription';
33
+ };