document-drive 0.0.26 → 0.0.28

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.
@@ -0,0 +1,2 @@
1
+ export * from './manager';
2
+ export * from './transmitter';
@@ -0,0 +1,382 @@
1
+ import {
2
+ ListenerCallInfo,
3
+ ListenerFilter
4
+ } from 'document-model-libs/document-drive';
5
+ import { OperationScope } from 'document-model/document';
6
+ import { OperationError } from '../error';
7
+ import {
8
+ BaseListenerManager,
9
+ ErrorStatus,
10
+ Listener,
11
+ ListenerState,
12
+ StrandUpdate,
13
+ SynchronizationUnit
14
+ } from '../types';
15
+ import { PullResponderTransmitter } from './transmitter';
16
+ import { SwitchboardPushTransmitter } from './transmitter/switchboard-push';
17
+ import { ITransmitter } from './transmitter/types';
18
+
19
+ export class ListenerManager extends BaseListenerManager {
20
+ async getTransmitter(
21
+ driveId: string,
22
+ listenerId: string
23
+ ): Promise<ITransmitter | undefined> {
24
+ return this.transmitters[driveId]?.[listenerId];
25
+ }
26
+
27
+ async addListener(listener: Listener) {
28
+ const drive = listener.driveId;
29
+
30
+ const syncUnits = await this.drive.getSynchronizationUnits(drive);
31
+ const filteredSyncUnits = [];
32
+ for (const syncUnit of syncUnits) {
33
+ if (this._checkFilter(listener.filter, syncUnit)) {
34
+ filteredSyncUnits.push(syncUnit);
35
+ }
36
+ }
37
+
38
+ if (!this.listenerState.has(drive)) {
39
+ this.listenerState.set(drive, new Map());
40
+ }
41
+
42
+ const driveMap = this.listenerState.get(drive)!;
43
+
44
+ const driveDocument = await this.drive.getDrive(drive);
45
+
46
+ const lastDriveOperation = driveDocument.operations.global
47
+ .slice()
48
+ .pop();
49
+
50
+ driveMap.set(listener.listenerId, {
51
+ block: listener.block,
52
+ driveId: listener.driveId,
53
+ pendingTimeout: '0',
54
+ listener,
55
+ listenerStatus: 'CREATED',
56
+ syncUnits: [
57
+ {
58
+ syncId: '0',
59
+ driveId: listener.driveId,
60
+ documentId: '',
61
+ documentType: driveDocument.documentType,
62
+ scope: 'global',
63
+ branch: 'main',
64
+ lastUpdated:
65
+ lastDriveOperation?.timestamp ??
66
+ driveDocument.lastModified,
67
+ revision: lastDriveOperation?.index ?? 0,
68
+ listenerRev: -1,
69
+ syncRev: lastDriveOperation?.index ?? 0
70
+ }
71
+ ].concat(
72
+ filteredSyncUnits.map(e => ({
73
+ ...e,
74
+ listenerRev: -1,
75
+ syncRev: e.revision
76
+ }))
77
+ )
78
+ });
79
+
80
+ let transmitter: ITransmitter | undefined;
81
+
82
+ switch (listener.callInfo?.transmitterType) {
83
+ case 'SwitchboardPush': {
84
+ transmitter = new SwitchboardPushTransmitter(
85
+ listener,
86
+ this.drive
87
+ );
88
+ break;
89
+ }
90
+
91
+ case 'PullResponder': {
92
+ transmitter = new PullResponderTransmitter(
93
+ listener,
94
+ this.drive,
95
+ this
96
+ );
97
+ }
98
+ }
99
+
100
+ if (!transmitter) {
101
+ throw new Error('Transmitter not found');
102
+ }
103
+
104
+ const driveTransmitters = this.transmitters[drive] || {};
105
+ driveTransmitters[listener.listenerId] = transmitter;
106
+ this.transmitters[drive] = driveTransmitters;
107
+ return transmitter;
108
+ }
109
+
110
+ async removeListener(driveId: string, listenerId: string) {
111
+ const driveMap = this.listenerState.get(driveId);
112
+ if (!driveMap) {
113
+ return false;
114
+ }
115
+
116
+ return driveMap.delete(listenerId);
117
+ }
118
+
119
+ async updateSynchronizationRevision(
120
+ driveId: string,
121
+ syncId: string,
122
+ syncRev: number,
123
+ lastUpdated: string
124
+ ) {
125
+ const drive = this.listenerState.get(driveId);
126
+ if (!drive) {
127
+ return;
128
+ }
129
+
130
+ let newRevision = false;
131
+ for (const [, listener] of drive) {
132
+ const syncUnits = listener.syncUnits.filter(
133
+ e => e.syncId === syncId
134
+ );
135
+ if (listener.driveId !== driveId) {
136
+ continue;
137
+ }
138
+
139
+ for (const syncUnit of syncUnits) {
140
+ if (syncUnit.syncId !== syncId) {
141
+ continue;
142
+ }
143
+
144
+ syncUnit.syncRev = syncRev;
145
+ syncUnit.lastUpdated = lastUpdated;
146
+ newRevision = true;
147
+ }
148
+ }
149
+
150
+ if (newRevision) {
151
+ return this.triggerUpdate();
152
+ }
153
+ }
154
+
155
+ async addSyncUnits(syncUnits: SynchronizationUnit[]) {
156
+ for (const [driveId, drive] of this.listenerState) {
157
+ for (const [id, listenerState] of drive) {
158
+ const transmitter = await this.getTransmitter(driveId, id);
159
+ if (!transmitter) {
160
+ continue;
161
+ }
162
+ const filteredSyncUnits = [];
163
+ const { listener } = listenerState;
164
+ for (const syncUnit of syncUnits) {
165
+ if (!this._checkFilter(listener.filter, syncUnit)) {
166
+ continue;
167
+ }
168
+ const existingSyncUnit = listenerState.syncUnits.find(
169
+ unit => unit.syncId === syncUnit.syncId
170
+ );
171
+ if (existingSyncUnit) {
172
+ existingSyncUnit.syncRev = syncUnit.revision;
173
+ existingSyncUnit.lastUpdated = syncUnit.lastUpdated;
174
+ } else {
175
+ filteredSyncUnits.push(syncUnit);
176
+ }
177
+ }
178
+
179
+ // TODO is this possible?
180
+ if (!this.listenerState.has(driveId)) {
181
+ this.listenerState.set(driveId, new Map());
182
+ }
183
+
184
+ const driveMap = this.listenerState.get(driveId)!;
185
+
186
+ // TODO reuse existing state
187
+ driveMap.set(listener.listenerId, {
188
+ block: listener.block,
189
+ driveId: listener.driveId,
190
+ pendingTimeout: '0',
191
+ listener,
192
+ listenerStatus: 'CREATED',
193
+ syncUnits: listenerState.syncUnits.concat(
194
+ filteredSyncUnits.map(e => ({
195
+ ...e,
196
+ listenerRev: -1,
197
+ syncRev: e.revision
198
+ }))
199
+ )
200
+ });
201
+ }
202
+ }
203
+ }
204
+
205
+ async updateListenerRevision(
206
+ listenerId: string,
207
+ driveId: string,
208
+ syncId: string,
209
+ listenerRev: number
210
+ ): Promise<void> {
211
+ const drive = this.listenerState.get(driveId);
212
+ if (!drive) {
213
+ return;
214
+ }
215
+
216
+ const listener = drive.get(listenerId);
217
+ if (!listener) {
218
+ return;
219
+ }
220
+
221
+ const entry = listener.syncUnits.find(s => s.syncId === syncId);
222
+ if (entry) {
223
+ entry.listenerRev = listenerRev;
224
+ entry.lastUpdated = new Date().toISOString();
225
+ }
226
+ }
227
+
228
+ async triggerUpdate() {
229
+ for (const [driveId, drive] of this.listenerState) {
230
+ for (const [id, listener] of drive) {
231
+ const transmitter = await this.getTransmitter(driveId, id);
232
+ if (!transmitter) {
233
+ continue;
234
+ }
235
+
236
+ const strandUpdates: StrandUpdate[] = [];
237
+ for (const unit of listener.syncUnits) {
238
+ const {
239
+ syncRev,
240
+ syncId,
241
+ listenerRev,
242
+ driveId,
243
+ documentId,
244
+ scope,
245
+ branch
246
+ } = unit;
247
+ if (listenerRev >= syncRev) {
248
+ continue;
249
+ }
250
+
251
+ const opData = await this.drive.getOperationData(
252
+ driveId,
253
+ syncId,
254
+ {
255
+ fromRevision: listenerRev
256
+ }
257
+ );
258
+
259
+ if (!opData.length) {
260
+ continue;
261
+ }
262
+
263
+ strandUpdates.push({
264
+ driveId,
265
+ documentId,
266
+ branch,
267
+ operations: opData,
268
+ scope: scope as OperationScope
269
+ });
270
+ }
271
+
272
+ if (strandUpdates.length == 0) {
273
+ continue;
274
+ }
275
+
276
+ listener.pendingTimeout = new Date(
277
+ new Date().getTime() / 1000 + 300
278
+ ).toISOString();
279
+ listener.listenerStatus = 'PENDING';
280
+
281
+ // TODO update listeners in parallel, blocking for listeners with block=true
282
+ try {
283
+ const listenerRevisions =
284
+ await transmitter?.transmit(strandUpdates);
285
+
286
+ listener.pendingTimeout = '0';
287
+ listener.listenerStatus = 'PENDING';
288
+
289
+ for (const unit of listener.syncUnits) {
290
+ const revision = listenerRevisions.find(
291
+ e =>
292
+ e.documentId === unit.documentId &&
293
+ e.scope === unit.scope &&
294
+ e.branch === unit.branch
295
+ );
296
+ if (revision) {
297
+ unit.listenerRev = revision.revision;
298
+ }
299
+ }
300
+ const revisionError = listenerRevisions.find(
301
+ l => l.status !== 'SUCCESS'
302
+ );
303
+ if (revisionError) {
304
+ throw new OperationError(
305
+ revisionError.status as ErrorStatus,
306
+ undefined
307
+ );
308
+ }
309
+ listener.listenerStatus = 'SUCCESS';
310
+ } catch (e) {
311
+ // TODO: Handle error based on listener params (blocking, retry, etc)
312
+ listener.listenerStatus =
313
+ e instanceof OperationError ? e.status : 'ERROR';
314
+ throw e;
315
+ }
316
+ }
317
+ }
318
+ }
319
+
320
+ private _checkFilter(
321
+ filter: ListenerFilter,
322
+ syncUnit: SynchronizationUnit
323
+ ) {
324
+ const { branch, documentId, scope, documentType } = syncUnit;
325
+ // TODO: Needs to be optimized
326
+ if (
327
+ (!filter.branch ||
328
+ filter.branch.includes(branch) ||
329
+ filter.branch.includes('*')) &&
330
+ (!filter.documentId ||
331
+ filter.documentId.includes(documentId) ||
332
+ filter.documentId.includes('*')) &&
333
+ (!filter.scope ||
334
+ filter.scope.includes(scope) ||
335
+ filter.scope.includes('*')) &&
336
+ (!filter.documentType ||
337
+ filter.documentType.includes(documentType) ||
338
+ filter.documentType.includes('*'))
339
+ ) {
340
+ return true;
341
+ }
342
+ return false;
343
+ }
344
+
345
+ async init() {
346
+ const drives = await this.drive.getDrives();
347
+ for (const driveId of drives) {
348
+ const drive = await this.drive.getDrive(driveId);
349
+ const {
350
+ state: {
351
+ local: { listeners }
352
+ }
353
+ } = drive;
354
+
355
+ for (const listener of listeners) {
356
+ this.addListener({
357
+ block: listener.block,
358
+ driveId,
359
+ filter: {
360
+ branch: listener.filter.branch ?? [],
361
+ documentId: listener.filter.documentId ?? [],
362
+ documentType: listener.filter.documentType,
363
+ scope: listener.filter.scope ?? []
364
+ },
365
+ listenerId: listener.listenerId,
366
+ system: listener.system,
367
+ callInfo:
368
+ (listener.callInfo as ListenerCallInfo) ?? undefined,
369
+ label: listener.label ?? ''
370
+ });
371
+ }
372
+ }
373
+ }
374
+
375
+ getListener(driveId: string, listenerId: string): ListenerState {
376
+ const drive = this.listenerState.get(driveId);
377
+ if (!drive) throw new Error('Drive not found');
378
+ const listener = drive.get(listenerId);
379
+ if (!listener) throw new Error('Listener not found');
380
+ return listener;
381
+ }
382
+ }
@@ -0,0 +1,3 @@
1
+ export * from './pull-responder';
2
+ export * from './switchboard-push';
3
+ export * from './types';
@@ -0,0 +1,308 @@
1
+ import { ListenerFilter, Trigger, z } from 'document-model-libs/document-drive';
2
+ import { Operation, OperationScope } from 'document-model/document';
3
+ import { PULL_DRIVE_INTERVAL } from '../..';
4
+ import { gql, requestGraphql } from '../../../utils/graphql';
5
+ import { OperationError } from '../../error';
6
+ import {
7
+ BaseDocumentDriveServer,
8
+ IOperationResult,
9
+ Listener,
10
+ ListenerRevision,
11
+ OperationUpdate,
12
+ StrandUpdate
13
+ } from '../../types';
14
+ import { ListenerManager } from '../manager';
15
+ import { ITransmitter, PullResponderTrigger } from './types';
16
+
17
+ export type OperationUpdateGraphQL = Omit<OperationUpdate, 'input'> & {
18
+ input: string;
19
+ };
20
+
21
+ export type PullStrandsGraphQL = {
22
+ system: {
23
+ sync: {
24
+ strands: StrandUpdateGraphQL[];
25
+ };
26
+ };
27
+ };
28
+
29
+ export type StrandUpdateGraphQL = Omit<StrandUpdate, 'operations'> & {
30
+ operations: OperationUpdateGraphQL[];
31
+ };
32
+
33
+ export class PullResponderTransmitter implements ITransmitter {
34
+ private drive: BaseDocumentDriveServer;
35
+ private listener: Listener;
36
+ private manager: ListenerManager;
37
+
38
+ constructor(
39
+ listener: Listener,
40
+ drive: BaseDocumentDriveServer,
41
+ manager: ListenerManager
42
+ ) {
43
+ this.listener = listener;
44
+ this.drive = drive;
45
+ this.manager = manager;
46
+ }
47
+
48
+ async transmit(): Promise<ListenerRevision[]> {
49
+ return [];
50
+ }
51
+
52
+ async getStrands(
53
+ listenerId: string,
54
+ since?: string
55
+ ): Promise<StrandUpdate[]> {
56
+ // fetch listenerState from listenerManager
57
+ const entries = this.manager.getListener(
58
+ this.listener.driveId,
59
+ listenerId
60
+ );
61
+
62
+ // fetch operations from drive and prepare strands
63
+ const strands: StrandUpdate[] = [];
64
+
65
+ for (const entry of entries.syncUnits) {
66
+ if (entry.listenerRev >= entry.syncRev) {
67
+ continue;
68
+ }
69
+
70
+ const { documentId, driveId, scope, branch } = entry;
71
+ const operations = await this.drive.getOperationData(
72
+ entry.driveId,
73
+ entry.syncId,
74
+ {
75
+ since,
76
+ fromRevision: entry.listenerRev
77
+ }
78
+ );
79
+ strands.push({
80
+ driveId,
81
+ documentId,
82
+ scope: scope as OperationScope,
83
+ branch,
84
+ operations
85
+ });
86
+ }
87
+
88
+ return strands;
89
+ }
90
+
91
+ async processAcknowledge(
92
+ driveId: string,
93
+ listenerId: string,
94
+ revisions: ListenerRevision[]
95
+ ): Promise<boolean> {
96
+ const listener = this.manager.getListener(driveId, listenerId);
97
+ let success = true;
98
+ for (const revision of revisions) {
99
+ const syncId = listener.syncUnits.find(
100
+ s => s.scope === revision.scope && s.branch === revision.branch && s.documentId === revision.documentId && s.driveId === driveId
101
+ )?.syncId;
102
+ if (!syncId) {
103
+ success = false;
104
+ continue;
105
+ }
106
+
107
+ await this.manager.updateListenerRevision(
108
+ listenerId,
109
+ driveId,
110
+ syncId,
111
+ revision.revision
112
+ );
113
+ }
114
+
115
+ return success;
116
+ }
117
+
118
+ static async registerPullResponder(
119
+ driveId: string,
120
+ url: string,
121
+ filter: ListenerFilter
122
+ ): Promise<Listener['listenerId']> {
123
+ // graphql request to switchboard
124
+ const { registerPullResponderListener } = await requestGraphql<{
125
+ registerPullResponderListener: {
126
+ listenerId: Listener['listenerId'];
127
+ };
128
+ }>(
129
+ url,
130
+ gql`
131
+ mutation registerPullResponderListener(
132
+ $filter: InputListenerFilter!
133
+ ) {
134
+ registerPullResponderListener(filter: $filter) {
135
+ listenerId
136
+ }
137
+ }
138
+ `,
139
+ { filter }
140
+ );
141
+ return registerPullResponderListener.listenerId;
142
+ }
143
+
144
+ static async pullStrands(
145
+ driveId: string,
146
+ url: string,
147
+ listenerId: string,
148
+ since?: string // TODO add support for since
149
+ ): Promise<StrandUpdate[]> {
150
+ const {
151
+ system: {
152
+ sync: { strands }
153
+ }
154
+ } = await requestGraphql<PullStrandsGraphQL>(
155
+ url,
156
+ gql`
157
+ query strands($listenerId: ID!) {
158
+ system {
159
+ sync {
160
+ strands(listenerId: $listenerId) {
161
+ driveId
162
+ documentId
163
+ scope
164
+ branch
165
+ operations {
166
+ timestamp
167
+ skip
168
+ type
169
+ input
170
+ hash
171
+ index
172
+ }
173
+ }
174
+ }
175
+ }
176
+ }
177
+ `,
178
+ { listenerId }
179
+ );
180
+ return strands.map(s => ({
181
+ ...s,
182
+ operations: s.operations.map(o => ({
183
+ ...o,
184
+ input: JSON.parse(o.input) as object
185
+ }))
186
+ }));
187
+ }
188
+
189
+ static async acknowledgeStrands(
190
+ driveId: string,
191
+ url: string,
192
+ listenerId: string,
193
+ revisions: ListenerRevision[]
194
+ ): Promise<boolean> {
195
+ const result = await requestGraphql<{ acknowledge: boolean }>(
196
+ url,
197
+ gql`
198
+ mutation acknowledge(
199
+ $listenerId: String!
200
+ $revisions: [ListenerRevisionInput]
201
+ ) {
202
+ acknowledge(listenerId: $listenerId, revisions: $revisions)
203
+ }
204
+ `,
205
+ { listenerId, revisions }
206
+ );
207
+ return result.acknowledge;
208
+ }
209
+
210
+ static setupPull(
211
+ driveId: string,
212
+ trigger: PullResponderTrigger,
213
+ onStrandUpdate: (strand: StrandUpdate) => Promise<IOperationResult>,
214
+ onError: (error: Error) => void,
215
+ onAcknowledge?: (success: boolean) => void
216
+ ): number {
217
+ const { url, listenerId, interval } = trigger.data;
218
+ let loopInterval = PULL_DRIVE_INTERVAL;
219
+ if (interval) {
220
+ try {
221
+ const intervalNumber = parseInt(interval);
222
+ if (intervalNumber) {
223
+ loopInterval = intervalNumber;
224
+ }
225
+ } catch {
226
+ // ignore invalid interval
227
+ }
228
+ }
229
+
230
+ const timeout = setInterval(async () => {
231
+ try {
232
+ const strands = await PullResponderTransmitter.pullStrands(
233
+ driveId,
234
+ url,
235
+ listenerId
236
+ // since ?
237
+ );
238
+ const listenerRevisions: ListenerRevision[] = [];
239
+
240
+ for (const strand of strands) {
241
+ const operations: Operation[] = strand.operations.map(
242
+ ({ index, type, hash, input, skip, timestamp }) => ({
243
+ index,
244
+ type,
245
+ hash,
246
+ input,
247
+ skip,
248
+ timestamp,
249
+ scope: strand.scope,
250
+ branch: strand.branch
251
+ })
252
+ );
253
+
254
+ let error: Error | undefined = undefined;
255
+
256
+ try {
257
+ const result = await onStrandUpdate(strand);
258
+ if (result.error) {
259
+ throw result.error;
260
+ }
261
+ } catch (e) {
262
+ error = e as Error;
263
+ onError?.(error);
264
+ }
265
+
266
+ listenerRevisions.push({
267
+ branch: strand.branch,
268
+ documentId: strand.documentId ?? '',
269
+ driveId: strand.driveId,
270
+ revision: operations.pop()?.index ?? -1,
271
+ scope: strand.scope as OperationScope,
272
+ status: error
273
+ ? error instanceof OperationError
274
+ ? error.status
275
+ : 'ERROR'
276
+ : 'SUCCESS'
277
+ });
278
+
279
+ // TODO: Should try to parse remaining strands?
280
+ if (error) {
281
+ break;
282
+ }
283
+ }
284
+
285
+ await PullResponderTransmitter.acknowledgeStrands(
286
+ driveId,
287
+ url,
288
+ listenerId,
289
+ listenerRevisions
290
+ )
291
+ .then(result => onAcknowledge?.(result))
292
+ .catch(error => console.error('ACK error', error));
293
+ } catch (error) {
294
+ onError(error as Error);
295
+ }
296
+ }, loopInterval);
297
+ return timeout as unknown as number;
298
+ }
299
+
300
+ static isPullResponderTrigger(
301
+ trigger: Trigger
302
+ ): trigger is PullResponderTrigger {
303
+ return (
304
+ trigger.type === 'PullResponder' &&
305
+ z.PullResponderTriggerDataSchema().safeParse(trigger.data).success
306
+ );
307
+ }
308
+ }