document-drive 1.0.0-alpha.2 → 1.0.0-alpha.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "document-drive",
3
- "version": "1.0.0-alpha.2",
3
+ "version": "1.0.0-alpha.21",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "module": "./src/index.ts",
@@ -30,8 +30,8 @@
30
30
  },
31
31
  "peerDependencies": {
32
32
  "@prisma/client": "5.8.1",
33
- "document-model": "^1.0.29",
34
- "document-model-libs": "^1.1.44",
33
+ "document-model": "^1.0.34",
34
+ "document-model-libs": "^1.1.51",
35
35
  "localforage": "^1.10.0",
36
36
  "sequelize": "^6.35.2",
37
37
  "sqlite3": "^5.1.7"
@@ -54,8 +54,8 @@
54
54
  "@typescript-eslint/eslint-plugin": "^6.18.1",
55
55
  "@typescript-eslint/parser": "^6.18.1",
56
56
  "@vitest/coverage-v8": "^0.34.6",
57
- "document-model": "^1.0.29",
58
- "document-model-libs": "^1.1.48",
57
+ "document-model": "^1.0.34",
58
+ "document-model-libs": "^1.17.1",
59
59
  "eslint": "^8.56.0",
60
60
  "eslint-config-prettier": "^9.1.0",
61
61
  "fake-indexeddb": "^5.0.1",
@@ -1,9 +1,13 @@
1
1
  import {
2
+ actions,
3
+ AddListenerInput,
2
4
  DocumentDriveAction,
3
5
  DocumentDriveDocument,
4
6
  DocumentDriveState,
5
7
  FileNode,
6
8
  isFileNode,
9
+ ListenerFilter,
10
+ RemoveListenerInput,
7
11
  Trigger,
8
12
  utils
9
13
  } from 'document-model-libs/document-drive';
@@ -19,18 +23,26 @@ import {
19
23
  import { createNanoEvents, Unsubscribe } from 'nanoevents';
20
24
  import { MemoryStorage } from '../storage/memory';
21
25
  import type { DocumentStorage, IDriveStorage } from '../storage/types';
22
- import { generateUUID, isDocumentDrive, isNoopUpdate } from '../utils';
26
+ import {
27
+ generateUUID,
28
+ isBefore,
29
+ isDocumentDrive,
30
+ isNoopUpdate
31
+ } from '../utils';
23
32
  import { requestPublicDrive } from '../utils/graphql';
24
33
  import { OperationError } from './error';
25
34
  import { ListenerManager } from './listener/manager';
26
35
  import {
27
36
  CancelPullLoop,
37
+ InternalTransmitter,
38
+ IReceiver,
28
39
  ITransmitter,
29
40
  PullResponderTransmitter
30
41
  } from './listener/transmitter';
31
42
  import {
32
43
  BaseDocumentDriveServer,
33
44
  DriveEvents,
45
+ GetDocumentOptions,
34
46
  IOperationResult,
35
47
  ListenerState,
36
48
  RemoteDriveOptions,
@@ -42,6 +54,7 @@ import {
42
54
  type SignalResult,
43
55
  type SynchronizationUnit
44
56
  } from './types';
57
+ import { filterOperationsByRevision } from './utils';
45
58
 
46
59
  export * from './listener';
47
60
  export type * from './types';
@@ -71,11 +84,15 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
71
84
 
72
85
  private updateSyncStatus(
73
86
  driveId: string,
74
- status: SyncStatus,
87
+ status: SyncStatus | null,
75
88
  error?: Error
76
89
  ) {
77
- this.syncStatus.set(driveId, status);
78
- this.emit('syncStatus', driveId, status, error);
90
+ if (status === null) {
91
+ this.syncStatus.delete(driveId);
92
+ } else if (this.getSyncStatus(driveId) !== status) {
93
+ this.syncStatus.set(driveId, status);
94
+ this.emit('syncStatus', driveId, status, error);
95
+ }
79
96
  }
80
97
 
81
98
  private async saveStrand(strand: StrandUpdate) {
@@ -178,17 +195,40 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
178
195
  private async stopSyncRemoteDrive(driveId: string) {
179
196
  const triggers = this.triggerMap.get(driveId);
180
197
  triggers?.forEach(cancel => cancel());
198
+ this.updateSyncStatus(driveId, null);
181
199
  return this.triggerMap.delete(driveId);
182
200
  }
183
201
 
184
202
  async initialize() {
185
203
  await this.listenerStateManager.init();
186
204
  const drives = await this.getDrives();
187
- for (const id of drives) {
188
- const drive = await this.getDrive(id);
189
- if (this.shouldSyncRemoteDrive(drive)) {
190
- this.startSyncRemoteDrive(id);
191
- }
205
+ for (const drive of drives) {
206
+ await this._initializeDrive(drive);
207
+ }
208
+ }
209
+
210
+ private async _initializeDrive(driveId: string) {
211
+ const drive = await this.getDrive(driveId);
212
+
213
+ if (this.shouldSyncRemoteDrive(drive)) {
214
+ await this.startSyncRemoteDrive(driveId);
215
+ }
216
+
217
+ for (const listener of drive.state.local.listeners) {
218
+ await this.listenerStateManager.addListener({
219
+ driveId,
220
+ block: listener.block,
221
+ filter: {
222
+ branch: listener.filter.branch ?? [],
223
+ documentId: listener.filter.documentId ?? [],
224
+ documentType: listener.filter.documentType ?? [],
225
+ scope: listener.filter.scope ?? []
226
+ },
227
+ listenerId: listener.listenerId,
228
+ system: listener.system,
229
+ callInfo: listener.callInfo ?? undefined,
230
+ label: listener.label ?? ''
231
+ });
192
232
  }
193
233
  }
194
234
 
@@ -203,13 +243,38 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
203
243
  const nodes = drive.state.global.nodes.filter(
204
244
  node =>
205
245
  isFileNode(node) &&
206
- (!documentId?.length || documentId.includes(node.id)) // TODO support * as documentId
207
- ) as FileNode[];
246
+ (!documentId?.length ||
247
+ documentId.includes(node.id) ||
248
+ documentId.includes('*'))
249
+ ) as Pick<
250
+ FileNode,
251
+ 'id' | 'documentType' | 'scopes' | 'synchronizationUnits'
252
+ >[];
208
253
 
209
254
  if (documentId && !nodes.length) {
210
255
  throw new Error('File node not found');
211
256
  }
212
257
 
258
+ // checks if document drive synchronization unit should be added
259
+ if (
260
+ !documentId ||
261
+ documentId.includes('*') ||
262
+ documentId.includes('')
263
+ ) {
264
+ nodes.unshift({
265
+ id: '',
266
+ documentType: 'powerhouse/document-drive',
267
+ scopes: ['global'],
268
+ synchronizationUnits: [
269
+ {
270
+ syncId: '0',
271
+ scope: 'global',
272
+ branch: 'main'
273
+ }
274
+ ]
275
+ });
276
+ }
277
+
213
278
  const synchronizationUnits: SynchronizationUnit[] = [];
214
279
 
215
280
  for (const node of nodes) {
@@ -225,7 +290,9 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
225
290
  continue;
226
291
  }
227
292
 
228
- const document = await this.getDocument(driveId, node.id);
293
+ const document = await (node.id
294
+ ? this.getDocument(driveId, node.id)
295
+ : this.getDrive(driveId));
229
296
 
230
297
  for (const { syncId, scope, branch } of nodeUnits) {
231
298
  const operations =
@@ -305,10 +372,10 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
305
372
  const filteredOperations = operations.filter(
306
373
  operation =>
307
374
  Object.keys(filter).length === 0 ||
308
- (filter.since !== undefined &&
309
- filter.since <= operation.timestamp) ||
310
- (filter.fromRevision !== undefined &&
311
- operation.index > filter.fromRevision)
375
+ ((filter.since === undefined ||
376
+ isBefore(filter.since, operation.timestamp)) &&
377
+ (filter.fromRevision === undefined ||
378
+ operation.index > filter.fromRevision))
312
379
  );
313
380
 
314
381
  return filteredOperations.map(operation => ({
@@ -349,30 +416,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
349
416
  });
350
417
 
351
418
  await this.storage.createDrive(id, document);
352
-
353
- // add listeners to state manager
354
- for (const listener of drive.local.listeners) {
355
- await this.listenerStateManager.addListener({
356
- block: listener.block,
357
- driveId: id,
358
- filter: {
359
- branch: listener.filter.branch ?? [],
360
- documentId: listener.filter.documentId ?? [],
361
- documentType: listener.filter.documentType ?? [],
362
- scope: listener.filter.scope ?? []
363
- },
364
- listenerId: listener.listenerId,
365
- system: listener.system,
366
- callInfo: listener.callInfo ?? undefined,
367
- label: listener.label ?? ''
368
- });
369
- }
370
-
371
- // if it is a remote drive that should be available offline, starts
372
- // the sync process to pull changes from remote every 30 seconds
373
- if (this.shouldSyncRemoteDrive(document)) {
374
- await this.startSyncRemoteDrive(id);
375
- }
419
+ await this._initializeDrive(id);
376
420
  }
377
421
 
378
422
  async addRemoteDrive(url: string, options: RemoteDriveOptions) {
@@ -385,26 +429,12 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
385
429
  listeners,
386
430
  triggers
387
431
  } = options;
388
- const listenerId = await PullResponderTransmitter.registerPullResponder(
389
- id,
390
- url,
391
- pullFilter ?? {
392
- documentId: ['*'],
393
- documentType: ['*'],
394
- branch: ['*'],
395
- scope: ['*']
396
- }
397
- );
398
432
 
399
- const pullTrigger: Trigger = {
400
- id: generateUUID(),
401
- type: 'PullResponder',
402
- data: {
403
- url,
404
- listenerId,
405
- interval: pullInterval?.toString() ?? ''
406
- }
407
- };
433
+ const pullTrigger =
434
+ await PullResponderTransmitter.createPullResponderTrigger(id, url, {
435
+ pullFilter,
436
+ pullInterval
437
+ });
408
438
 
409
439
  return await this.addDrive({
410
440
  global: {
@@ -431,12 +461,15 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
431
461
  return this.storage.getDrives();
432
462
  }
433
463
 
434
- async getDrive(drive: string) {
464
+ async getDrive(drive: string, options?: GetDocumentOptions) {
435
465
  const driveStorage = await this.storage.getDrive(drive);
436
466
  const documentModel = this._getDocumentModel(driveStorage.documentType);
437
467
  const document = baseUtils.replayDocument(
438
468
  driveStorage.initialState,
439
- driveStorage.operations,
469
+ filterOperationsByRevision(
470
+ driveStorage.operations,
471
+ options?.revisions
472
+ ),
440
473
  documentModel.reducer,
441
474
  undefined,
442
475
  driveStorage
@@ -450,7 +483,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
450
483
  }
451
484
  }
452
485
 
453
- async getDocument(drive: string, id: string) {
486
+ async getDocument(drive: string, id: string, options?: GetDocumentOptions) {
454
487
  const { initialState, operations, ...header } =
455
488
  await this.storage.getDocument(drive, id);
456
489
 
@@ -458,7 +491,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
458
491
 
459
492
  return baseUtils.replayDocument(
460
493
  initialState,
461
- operations,
494
+ filterOperationsByRevision(operations, options?.revisions),
462
495
  documentModel.reducer,
463
496
  undefined,
464
497
  header
@@ -859,6 +892,14 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
859
892
  return this.addDriveOperations(drive, [operation]);
860
893
  }
861
894
 
895
+ async clearStorage() {
896
+ for (const drive of await this.getDrives()) {
897
+ await this.deleteDrive(drive);
898
+ }
899
+
900
+ await this.storage.clearStorage?.();
901
+ }
902
+
862
903
  async addDriveOperations(
863
904
  drive: string,
864
905
  operations: Operation<DocumentDriveAction | BaseAction>[]
@@ -897,33 +938,15 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
897
938
  }
898
939
 
899
940
  for (const operation of operationsApplied) {
900
- if (operation.type === 'ADD_LISTENER') {
901
- const { listener } = operation.input;
902
- await this.listenerStateManager.addListener({
903
- ...listener,
904
- driveId: drive,
905
- label: listener.label ?? '',
906
- system: listener.system ?? false,
907
- filter: {
908
- branch: listener.filter.branch ?? [],
909
- documentId: listener.filter.documentId ?? [],
910
- documentType: listener.filter.documentType ?? [],
911
- scope: listener.filter.scope ?? []
912
- },
913
- callInfo: {
914
- data: listener.callInfo?.data ?? '',
915
- name: listener.callInfo?.name ?? 'PullResponder',
916
- transmitterType:
917
- listener.callInfo?.transmitterType ??
918
- 'PullResponder'
919
- }
920
- });
921
- } else if (operation.type === 'REMOVE_LISTENER') {
922
- const { listenerId } = operation.input;
923
- await this.listenerStateManager.removeListener(
924
- drive,
925
- listenerId
926
- );
941
+ switch (operation.type) {
942
+ case 'ADD_LISTENER': {
943
+ await this.addListener(drive, operation);
944
+ break;
945
+ }
946
+ case 'REMOVE_LISTENER': {
947
+ await this.removeListener(drive, operation);
948
+ break;
949
+ }
927
950
  }
928
951
  }
929
952
 
@@ -995,6 +1018,149 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
995
1018
  }
996
1019
  }
997
1020
 
1021
+ private _buildOperation<T extends Action>(
1022
+ documentStorage: DocumentStorage,
1023
+ action: T | BaseAction
1024
+ ): Operation<T | BaseAction> {
1025
+ const [operation] = this._buildOperations(documentStorage, [action]);
1026
+ if (!operation) {
1027
+ throw new Error('Error creating operation');
1028
+ }
1029
+ return operation;
1030
+ }
1031
+
1032
+ private _buildOperations<T extends Action>(
1033
+ documentStorage: DocumentStorage,
1034
+ actions: (T | BaseAction)[]
1035
+ ): Operation<T | BaseAction>[] {
1036
+ const operations: Operation<T | BaseAction>[] = [];
1037
+ const { reducer } = this._getDocumentModel(
1038
+ documentStorage.documentType
1039
+ );
1040
+ let document = baseUtils.replayDocument(
1041
+ documentStorage.initialState,
1042
+ documentStorage.operations,
1043
+ reducer,
1044
+ undefined,
1045
+ documentStorage
1046
+ );
1047
+ for (const action of actions) {
1048
+ document = reducer(document, action);
1049
+ const operation = document.operations[action.scope].slice().pop();
1050
+ if (!operation) {
1051
+ throw new Error('Error creating operations');
1052
+ }
1053
+ operations.push(operation);
1054
+ }
1055
+ return operations;
1056
+ }
1057
+
1058
+ async addAction(
1059
+ drive: string,
1060
+ id: string,
1061
+ action: Action
1062
+ ): Promise<IOperationResult> {
1063
+ const documentStorage = await this.storage.getDocument(drive, id);
1064
+ const operation = this._buildOperation(documentStorage, action);
1065
+ return this.addOperation(drive, id, operation);
1066
+ }
1067
+
1068
+ async addActions(
1069
+ drive: string,
1070
+ id: string,
1071
+ actions: Action[]
1072
+ ): Promise<IOperationResult> {
1073
+ const documentStorage = await this.storage.getDocument(drive, id);
1074
+ const operations = this._buildOperations(documentStorage, actions);
1075
+ return this.addOperations(drive, id, operations);
1076
+ }
1077
+
1078
+ async addDriveAction(
1079
+ drive: string,
1080
+ action: DocumentDriveAction | BaseAction
1081
+ ): Promise<IOperationResult<DocumentDriveDocument>> {
1082
+ const documentStorage = await this.storage.getDrive(drive);
1083
+ const operation = this._buildOperation(documentStorage, action);
1084
+ return this.addDriveOperation(drive, operation);
1085
+ }
1086
+
1087
+ async addDriveActions(
1088
+ drive: string,
1089
+ actions: (DocumentDriveAction | BaseAction)[]
1090
+ ): Promise<IOperationResult<DocumentDriveDocument>> {
1091
+ const documentStorage = await this.storage.getDrive(drive);
1092
+ const operations = this._buildOperations(documentStorage, actions);
1093
+ return this.addDriveOperations(drive, operations);
1094
+ }
1095
+
1096
+ async addInternalListener(
1097
+ driveId: string,
1098
+ receiver: IReceiver,
1099
+ options: {
1100
+ listenerId: string;
1101
+ label: string;
1102
+ block: boolean;
1103
+ filter: ListenerFilter;
1104
+ }
1105
+ ) {
1106
+ const listener: AddListenerInput['listener'] = {
1107
+ callInfo: {
1108
+ data: '',
1109
+ name: 'Interal',
1110
+ transmitterType: 'Internal'
1111
+ },
1112
+ system: true,
1113
+ ...options
1114
+ };
1115
+ await this.addDriveAction(driveId, actions.addListener({ listener }));
1116
+ const transmitter = await this.getTransmitter(
1117
+ driveId,
1118
+ options.listenerId
1119
+ );
1120
+ if (!transmitter) {
1121
+ throw new Error('Internal listener not found');
1122
+ }
1123
+ if (!(transmitter instanceof InternalTransmitter)) {
1124
+ throw new Error('Listener is not an internal transmitter');
1125
+ }
1126
+
1127
+ transmitter.setReceiver(receiver);
1128
+ return transmitter;
1129
+ }
1130
+
1131
+ private async addListener(
1132
+ driveId: string,
1133
+ operation: Operation<Action<'ADD_LISTENER', AddListenerInput>>
1134
+ ) {
1135
+ const { listener } = operation.input;
1136
+ await this.listenerStateManager.addListener({
1137
+ ...listener,
1138
+ driveId,
1139
+ label: listener.label ?? '',
1140
+ system: listener.system ?? false,
1141
+ filter: {
1142
+ branch: listener.filter.branch ?? [],
1143
+ documentId: listener.filter.documentId ?? [],
1144
+ documentType: listener.filter.documentType ?? [],
1145
+ scope: listener.filter.scope ?? []
1146
+ },
1147
+ callInfo: {
1148
+ data: listener.callInfo?.data ?? '',
1149
+ name: listener.callInfo?.name ?? 'PullResponder',
1150
+ transmitterType:
1151
+ listener.callInfo?.transmitterType ?? 'PullResponder'
1152
+ }
1153
+ });
1154
+ }
1155
+
1156
+ private async removeListener(
1157
+ driveId: string,
1158
+ operation: Operation<Action<'REMOVE_LISTENER', RemoveListenerInput>>
1159
+ ) {
1160
+ const { listenerId } = operation.input;
1161
+ await this.listenerStateManager.removeListener(driveId, listenerId);
1162
+ }
1163
+
998
1164
  getTransmitter(
999
1165
  driveId: string,
1000
1166
  listenerId: string
@@ -15,6 +15,7 @@ import {
15
15
  SynchronizationUnit
16
16
  } from '../types';
17
17
  import { PullResponderTransmitter } from './transmitter';
18
+ import { InternalTransmitter } from './transmitter/internal';
18
19
  import { SwitchboardPushTransmitter } from './transmitter/switchboard-push';
19
20
  import { ITransmitter } from './transmitter/types';
20
21
 
@@ -42,41 +43,17 @@ export class ListenerManager extends BaseListenerManager {
42
43
  }
43
44
 
44
45
  const driveMap = this.listenerState.get(drive)!;
45
-
46
- const driveDocument = await this.drive.getDrive(drive);
47
-
48
- const lastDriveOperation = driveDocument.operations.global
49
- .slice()
50
- .pop();
51
-
52
46
  driveMap.set(listener.listenerId, {
53
47
  block: listener.block,
54
48
  driveId: listener.driveId,
55
49
  pendingTimeout: '0',
56
50
  listener,
57
51
  listenerStatus: 'CREATED',
58
- syncUnits: [
59
- {
60
- syncId: '0',
61
- driveId: listener.driveId,
62
- documentId: '',
63
- documentType: driveDocument.documentType,
64
- scope: 'global',
65
- branch: 'main',
66
- lastUpdated:
67
- lastDriveOperation?.timestamp ??
68
- driveDocument.lastModified,
69
- revision: lastDriveOperation?.index ?? 0,
70
- listenerRev: -1,
71
- syncRev: lastDriveOperation?.index ?? 0
72
- }
73
- ].concat(
74
- filteredSyncUnits.map(e => ({
75
- ...e,
76
- listenerRev: -1,
77
- syncRev: e.revision
78
- }))
79
- )
52
+ syncUnits: filteredSyncUnits.map(e => ({
53
+ ...e,
54
+ listenerRev: -1,
55
+ syncRev: e.revision
56
+ }))
80
57
  });
81
58
 
82
59
  let transmitter: ITransmitter | undefined;
@@ -96,6 +73,11 @@ export class ListenerManager extends BaseListenerManager {
96
73
  this.drive,
97
74
  this
98
75
  );
76
+ break;
77
+ }
78
+ case 'Internal': {
79
+ transmitter = new InternalTransmitter(listener, this.drive);
80
+ break;
99
81
  }
100
82
  }
101
83
 
@@ -1,3 +1,4 @@
1
1
  export * from './pull-responder';
2
2
  export * from './switchboard-push';
3
+ export * from './internal';
3
4
  export * from './types';
@@ -0,0 +1,90 @@
1
+ import { Document, OperationScope } from 'document-model/document';
2
+ import {
3
+ BaseDocumentDriveServer,
4
+ Listener,
5
+ ListenerRevision,
6
+ OperationUpdate,
7
+ StrandUpdate
8
+ } from '../../types';
9
+ import { buildRevisionsFilter } from '../../utils';
10
+ import { ITransmitter } from './types';
11
+
12
+ export interface IReceiver {
13
+ transmit: (strands: InternalTransmitterUpdate[]) => Promise<void>;
14
+ }
15
+
16
+ export type InternalTransmitterUpdate<
17
+ T extends Document = Document,
18
+ S extends OperationScope = OperationScope
19
+ > = {
20
+ driveId: string;
21
+ documentId: string;
22
+ scope: S;
23
+ branch: string;
24
+ operations: OperationUpdate[];
25
+ state: T['state'][S];
26
+ };
27
+
28
+ export class InternalTransmitter implements ITransmitter {
29
+ private drive: BaseDocumentDriveServer;
30
+ private listener: Listener;
31
+ private receiver: IReceiver | undefined;
32
+
33
+ constructor(listener: Listener, drive: BaseDocumentDriveServer) {
34
+ this.listener = listener;
35
+ this.drive = drive;
36
+ }
37
+
38
+ async transmit(strands: StrandUpdate[]): Promise<ListenerRevision[]> {
39
+ if (!this.receiver) {
40
+ return [];
41
+ }
42
+
43
+ const retrievedDocuments = new Map<string, Document>();
44
+ const updates: InternalTransmitterUpdate[] = [];
45
+ for (const strand of strands) {
46
+ let document = retrievedDocuments.get(
47
+ `${strand.driveId}:${strand.documentId}`
48
+ );
49
+ if (!document) {
50
+ const revisions = buildRevisionsFilter(
51
+ strands,
52
+ strand.driveId,
53
+ strand.documentId
54
+ );
55
+ document = await (strand.documentId
56
+ ? this.drive.getDocument(
57
+ strand.driveId,
58
+ strand.documentId,
59
+ { revisions }
60
+ )
61
+ : this.drive.getDrive(strand.driveId, { revisions }));
62
+ retrievedDocuments.set(
63
+ `${strand.driveId}:${strand.documentId}`,
64
+ document
65
+ );
66
+ }
67
+ updates.push({ ...strand, state: document.state[strand.scope] });
68
+ }
69
+ try {
70
+ await this.receiver.transmit(updates);
71
+ return strands.map(({ operations, ...s }) => ({
72
+ ...s,
73
+ status: 'SUCCESS',
74
+ revision: operations[operations.length - 1]?.index ?? -1
75
+ }));
76
+ } catch (error) {
77
+ console.error(error);
78
+ // TODO check which strand caused an error
79
+ return strands.map(({ operations, ...s }) => ({
80
+ ...s,
81
+ status: 'ERROR',
82
+ revision: (operations[0]?.index ?? 0) - 1
83
+ }));
84
+ }
85
+ }
86
+
87
+ setReceiver(receiver: IReceiver) {
88
+ this.receiver = receiver;
89
+ }
90
+ }
@@ -1,6 +1,7 @@
1
1
  import { ListenerFilter, Trigger, z } from 'document-model-libs/document-drive';
2
2
  import { Operation, OperationScope } from 'document-model/document';
3
3
  import { PULL_DRIVE_INTERVAL } from '../..';
4
+ import { generateUUID } from '../../../utils';
4
5
  import { gql, requestGraphql } from '../../../utils/graphql';
5
6
  import { OperationError } from '../../error';
6
7
  import {
@@ -10,6 +11,7 @@ import {
10
11
  ListenerRevision,
11
12
  ListenerRevisionWithError,
12
13
  OperationUpdate,
14
+ RemoteDriveOptions,
13
15
  StrandUpdate
14
16
  } from '../../types';
15
17
  import { ListenerManager } from '../manager';
@@ -208,6 +210,7 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
208
210
 
209
211
  // if there are no new strands then do nothing
210
212
  if (!strands.length) {
213
+ onRevisions?.([]);
211
214
  return;
212
215
  }
213
216
 
@@ -329,6 +332,35 @@ export class PullResponderTransmitter implements IPullResponderTransmitter {
329
332
  };
330
333
  }
331
334
 
335
+ static async createPullResponderTrigger(
336
+ driveId: string,
337
+ url: string,
338
+ options: Pick<RemoteDriveOptions, 'pullInterval' | 'pullFilter'>
339
+ ): Promise<PullResponderTrigger> {
340
+ const { pullFilter, pullInterval } = options;
341
+ const listenerId = await PullResponderTransmitter.registerPullResponder(
342
+ driveId,
343
+ url,
344
+ pullFilter ?? {
345
+ documentId: ['*'],
346
+ documentType: ['*'],
347
+ branch: ['*'],
348
+ scope: ['*']
349
+ }
350
+ );
351
+
352
+ const pullTrigger: PullResponderTrigger = {
353
+ id: generateUUID(),
354
+ type: 'PullResponder',
355
+ data: {
356
+ url,
357
+ listenerId,
358
+ interval: pullInterval?.toString() ?? ''
359
+ }
360
+ };
361
+ return pullTrigger;
362
+ }
363
+
332
364
  static isPullResponderTrigger(
333
365
  trigger: Trigger
334
366
  ): trigger is PullResponderTrigger {
@@ -7,6 +7,7 @@ import type {
7
7
  ListenerFilter
8
8
  } from 'document-model-libs/document-drive';
9
9
  import type {
10
+ Action,
10
11
  BaseAction,
11
12
  CreateChildDocumentInput,
12
13
  Document,
@@ -127,6 +128,17 @@ export interface DriveEvents {
127
128
  strandUpdate: (update: StrandUpdate) => void;
128
129
  }
129
130
 
131
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
132
+ export type PartialRecord<K extends keyof any, T> = {
133
+ [P in K]?: T;
134
+ };
135
+
136
+ export type RevisionsFilter = PartialRecord<OperationScope, number>;
137
+
138
+ export type GetDocumentOptions = {
139
+ revisions?: RevisionsFilter;
140
+ };
141
+
130
142
  export abstract class BaseDocumentDriveServer {
131
143
  /** Public methods **/
132
144
  abstract getDrives(): Promise<string[]>;
@@ -136,10 +148,17 @@ export abstract class BaseDocumentDriveServer {
136
148
  options: RemoteDriveOptions
137
149
  ): Promise<void>;
138
150
  abstract deleteDrive(id: string): Promise<void>;
139
- abstract getDrive(id: string): Promise<DocumentDriveDocument>;
151
+ abstract getDrive(
152
+ id: string,
153
+ options?: GetDocumentOptions
154
+ ): Promise<DocumentDriveDocument>;
140
155
 
141
156
  abstract getDocuments(drive: string): Promise<string[]>;
142
- abstract getDocument(drive: string, id: string): Promise<Document>;
157
+ abstract getDocument(
158
+ drive: string,
159
+ id: string,
160
+ options?: GetDocumentOptions
161
+ ): Promise<Document>;
143
162
 
144
163
  abstract addOperation(
145
164
  drive: string,
@@ -161,6 +180,26 @@ export abstract class BaseDocumentDriveServer {
161
180
  operations: Operation<DocumentDriveAction | BaseAction>[]
162
181
  ): Promise<IOperationResult<DocumentDriveDocument>>;
163
182
 
183
+ abstract addAction(
184
+ drive: string,
185
+ id: string,
186
+ action: Action
187
+ ): Promise<IOperationResult>;
188
+ abstract addActions(
189
+ drive: string,
190
+ id: string,
191
+ actions: Action[]
192
+ ): Promise<IOperationResult>;
193
+
194
+ abstract addDriveAction(
195
+ drive: string,
196
+ action: DocumentDriveAction | BaseAction
197
+ ): Promise<IOperationResult<DocumentDriveDocument>>;
198
+ abstract addDriveActions(
199
+ drive: string,
200
+ actions: (DocumentDriveAction | BaseAction)[]
201
+ ): Promise<IOperationResult<DocumentDriveDocument>>;
202
+
164
203
  abstract getSyncStatus(drive: string): SyncStatus;
165
204
 
166
205
  /** Synchronization methods */
@@ -208,6 +247,8 @@ export abstract class BaseDocumentDriveServer {
208
247
  driveId: string,
209
248
  listenerId: string
210
249
  ): Promise<ITransmitter | undefined>;
250
+
251
+ abstract clearStorage(): Promise<void>;
211
252
  }
212
253
 
213
254
  export abstract class BaseListenerManager {
@@ -0,0 +1,34 @@
1
+ import type { Document, OperationScope } from 'document-model/document';
2
+ import { RevisionsFilter, StrandUpdate } from './types';
3
+
4
+ export function buildRevisionsFilter(
5
+ strands: StrandUpdate[],
6
+ driveId: string,
7
+ documentId: string
8
+ ): RevisionsFilter {
9
+ return strands.reduce<RevisionsFilter>((acc, s) => {
10
+ if (!(s.driveId === driveId && s.documentId === documentId)) {
11
+ return acc;
12
+ }
13
+ acc[s.scope] = s.operations[s.operations.length - 1]?.index ?? -1;
14
+ return acc;
15
+ }, {});
16
+ }
17
+
18
+ export function filterOperationsByRevision(
19
+ operations: Document['operations'],
20
+ revisions?: RevisionsFilter
21
+ ): Document['operations'] {
22
+ if (!revisions) {
23
+ return operations;
24
+ }
25
+ return (Object.keys(operations) as OperationScope[]).reduce<
26
+ Document['operations']
27
+ >((acc, scope) => {
28
+ const revision = revisions[scope];
29
+ if (revision !== undefined) {
30
+ acc[scope] = operations[scope].filter(op => op.index <= revision);
31
+ }
32
+ return acc;
33
+ }, operations);
34
+ }
@@ -53,6 +53,10 @@ export class BrowserStorage implements IDriveStorage {
53
53
  await (await this.db).removeItem(this.buildKey(drive, id));
54
54
  }
55
55
 
56
+ async clearStorage(): Promise<void> {
57
+ return (await this.db).clear();
58
+ }
59
+
56
60
  async addDocumentOperations(
57
61
  drive: string,
58
62
  id: string,
@@ -96,6 +96,44 @@ export class FilesystemStorage implements IDriveStorage {
96
96
  });
97
97
  }
98
98
 
99
+ async clearStorage() {
100
+ const drivesPath = path.join(
101
+ this.basePath,
102
+ FilesystemStorage.DRIVES_DIR
103
+ );
104
+
105
+ // delete content of drives directory
106
+ const drives = (
107
+ await fs.readdir(drivesPath, {
108
+ withFileTypes: true,
109
+ recursive: true
110
+ })
111
+ ).filter(dirent => !!dirent.name);
112
+
113
+ await Promise.all(
114
+ drives.map(async dirent => {
115
+ await fs.rm(path.join(drivesPath, dirent.name), {
116
+ recursive: true
117
+ });
118
+ })
119
+ );
120
+
121
+ // delete files in basePath
122
+ const files = (
123
+ await fs.readdir(this.basePath, { withFileTypes: true })
124
+ ).filter(
125
+ file => file.name !== FilesystemStorage.DRIVES_DIR && !!file.name
126
+ );
127
+
128
+ await Promise.all(
129
+ files.map(async dirent => {
130
+ await fs.rm(path.join(this.basePath, dirent.name), {
131
+ recursive: true
132
+ });
133
+ })
134
+ );
135
+ }
136
+
99
137
  async deleteDocument(drive: string, id: string) {
100
138
  return fs.rm(this._buildDocumentPath(drive, id));
101
139
  }
@@ -39,6 +39,11 @@ export class MemoryStorage implements IDriveStorage {
39
39
  this.documents[drive]![id] = document;
40
40
  }
41
41
 
42
+ async clearStorage(): Promise<void> {
43
+ this.documents = {};
44
+ this.drives = {};
45
+ }
46
+
42
47
  async createDocument(drive: string, id: string, document: DocumentStorage) {
43
48
  this.documents[drive] = this.documents[drive] ?? {};
44
49
  const {
@@ -58,53 +58,33 @@ export class PrismaStorage implements IDriveStorage {
58
58
  drive: string,
59
59
  id: string,
60
60
  operations: Operation[],
61
- header: DocumentHeader
61
+ header: DocumentHeader,
62
+ updatedOperations: Operation[] = []
62
63
  ): Promise<void> {
63
64
  const document = await this.getDocument(drive, id);
64
65
  if (!document) {
65
66
  throw new Error(`Document with id ${id} not found`);
66
67
  }
67
68
 
69
+ const mergedOperations = [...operations, ...updatedOperations].sort(
70
+ (a, b) => a.index - b.index
71
+ );
72
+
68
73
  try {
69
- await Promise.all(
70
- operations.map(async op => {
71
- return this.db.operation.upsert({
72
- where: {
73
- driveId_documentId_scope_branch_index: {
74
- driveId: drive,
75
- documentId: id,
76
- scope: op.scope,
77
- branch: 'main',
78
- index: op.index
79
- }
80
- },
81
- create: {
82
- driveId: drive,
83
- documentId: id,
84
- hash: op.hash,
85
- index: op.index,
86
- input: op.input as Prisma.InputJsonObject,
87
- timestamp: op.timestamp,
88
- type: op.type,
89
- scope: op.scope,
90
- branch: 'main',
91
- skip: op.skip
92
- },
93
- update: {
94
- driveId: drive,
95
- documentId: id,
96
- hash: op.hash,
97
- index: op.index,
98
- input: op.input as Prisma.InputJsonObject,
99
- timestamp: op.timestamp,
100
- type: op.type,
101
- scope: op.scope,
102
- branch: 'main',
103
- skip: op.skip
104
- }
105
- });
106
- })
107
- );
74
+ await this.db.operation.createMany({
75
+ data: mergedOperations.map(op => ({
76
+ driveId: drive,
77
+ documentId: id,
78
+ hash: op.hash,
79
+ index: op.index,
80
+ input: op.input as Prisma.InputJsonObject,
81
+ timestamp: op.timestamp,
82
+ type: op.type,
83
+ scope: op.scope,
84
+ branch: 'main',
85
+ skip: op.skip
86
+ }))
87
+ });
108
88
 
109
89
  await this.db.document.updateMany({
110
90
  where: {
@@ -213,48 +193,21 @@ export class PrismaStorage implements IDriveStorage {
213
193
  }
214
194
 
215
195
  async deleteDocument(drive: string, id: string) {
216
- await this.db.attachment.deleteMany({
217
- where: {
218
- driveId: drive,
219
- documentId: id
220
- }
221
- });
222
-
223
- await this.db.operation.deleteMany({
224
- where: {
225
- driveId: drive,
226
- documentId: id
227
- }
228
- });
229
-
230
196
  await this.db.document.delete({
231
197
  where: {
232
198
  id_driveId: {
233
199
  driveId: drive,
234
200
  id: id
235
201
  }
202
+ },
203
+ include: {
204
+ operations: {
205
+ include: {
206
+ attachments: true
207
+ }
208
+ }
236
209
  }
237
210
  });
238
-
239
- if (drive === 'drives') {
240
- await this.db.attachment.deleteMany({
241
- where: {
242
- driveId: id
243
- }
244
- });
245
-
246
- await this.db.operation.deleteMany({
247
- where: {
248
- driveId: id
249
- }
250
- });
251
-
252
- await this.db.document.deleteMany({
253
- where: {
254
- driveId: id
255
- }
256
- });
257
- }
258
211
  }
259
212
 
260
213
  async getDrives() {
@@ -271,6 +224,12 @@ export class PrismaStorage implements IDriveStorage {
271
224
  }
272
225
 
273
226
  async deleteDrive(id: string) {
227
+ const docs = await this.getDocuments(id);
228
+ await Promise.all(
229
+ docs.map(async doc => {
230
+ return this.deleteDocument(id, doc);
231
+ })
232
+ );
274
233
  await this.deleteDocument('drives', id);
275
234
  }
276
235
  }
@@ -38,6 +38,7 @@ export interface IDriveStorage extends IStorage {
38
38
  getDrive(id: string): Promise<DocumentDriveStorage>;
39
39
  createDrive(id: string, drive: DocumentDriveStorage): Promise<void>;
40
40
  deleteDrive(id: string): Promise<void>;
41
+ clearStorage?(): Promise<void>;
41
42
  addDriveOperations(
42
43
  id: string,
43
44
  operations: Operation<DocumentDriveAction | BaseAction>[],
@@ -75,3 +75,8 @@ export function isNoopUpdate(
75
75
  isSkipOpGreaterThanLatestOp
76
76
  );
77
77
  }
78
+
79
+ // return true if dateA is before dateB
80
+ export function isBefore(dateA: Date | string, dateB: Date | string) {
81
+ return new Date(dateA) < new Date(dateB);
82
+ }