document-drive 0.0.28 → 0.0.30

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": "0.0.28",
3
+ "version": "0.0.30",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "module": "./src/index.ts",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "peerDependencies": {
31
31
  "@prisma/client": "5.8.1",
32
- "document-model": "^1.0.28",
32
+ "document-model": "^1.0.29",
33
33
  "document-model-libs": "^1.1.44",
34
34
  "localforage": "^1.10.0",
35
35
  "sequelize": "^6.35.2",
@@ -39,6 +39,7 @@
39
39
  "graphql": "^16.8.1",
40
40
  "graphql-request": "^6.1.0",
41
41
  "json-stringify-deterministic": "^1.0.12",
42
+ "nanoevents": "^9.0.0",
42
43
  "sanitize-filename": "^1.6.3"
43
44
  },
44
45
  "devDependencies": {
@@ -48,7 +49,7 @@
48
49
  "@typescript-eslint/eslint-plugin": "^6.18.1",
49
50
  "@typescript-eslint/parser": "^6.18.1",
50
51
  "@vitest/coverage-v8": "^0.34.6",
51
- "document-model": "^1.0.28",
52
+ "document-model": "^1.0.29",
52
53
  "document-model-libs": "^1.1.44",
53
54
  "eslint": "^8.56.0",
54
55
  "eslint-config-prettier": "^9.1.0",
@@ -3,22 +3,23 @@ import {
3
3
  DocumentDriveDocument,
4
4
  DocumentDriveState,
5
5
  FileNode,
6
- Trigger,
7
6
  isFileNode,
7
+ Trigger,
8
8
  utils
9
9
  } from 'document-model-libs/document-drive';
10
10
  import {
11
11
  Action,
12
12
  BaseAction,
13
+ utils as baseUtils,
13
14
  Document,
14
15
  DocumentModel,
15
16
  Operation,
16
- OperationScope,
17
- utils as baseUtils
17
+ OperationScope
18
18
  } from 'document-model/document';
19
+ import { createNanoEvents, Unsubscribe } from 'nanoevents';
19
20
  import { MemoryStorage } from '../storage/memory';
20
21
  import type { DocumentStorage, IDriveStorage } from '../storage/types';
21
- import { generateUUID, isDocumentDrive } from '../utils';
22
+ import { generateUUID, isDocumentDrive, isNoopUpdate } from '../utils';
22
23
  import { requestPublicDrive } from '../utils/graphql';
23
24
  import { OperationError } from './error';
24
25
  import { ListenerManager } from './listener/manager';
@@ -26,7 +27,9 @@ import { PullResponderTransmitter } from './listener/transmitter';
26
27
  import type { ITransmitter } from './listener/transmitter/types';
27
28
  import {
28
29
  BaseDocumentDriveServer,
30
+ DriveEvents,
29
31
  IOperationResult,
32
+ ListenerState,
30
33
  RemoteDriveOptions,
31
34
  StrandUpdate,
32
35
  SyncStatus,
@@ -43,6 +46,7 @@ export type * from './types';
43
46
  export const PULL_DRIVE_INTERVAL = 5000;
44
47
 
45
48
  export class DocumentDriveServer extends BaseDocumentDriveServer {
49
+ private emitter = createNanoEvents<DriveEvents>();
46
50
  private documentModels: DocumentModel[];
47
51
  private storage: IDriveStorage;
48
52
  private listenerStateManager: ListenerManager;
@@ -62,6 +66,15 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
62
66
  this.storage = storage;
63
67
  }
64
68
 
69
+ private updateSyncStatus(
70
+ driveId: string,
71
+ status: SyncStatus,
72
+ error?: Error
73
+ ) {
74
+ this.syncStatus.set(driveId, status);
75
+ this.emit('syncStatus', driveId, status, error);
76
+ }
77
+
65
78
  private async saveStrand(strand: StrandUpdate) {
66
79
  const operations: Operation[] = strand.operations.map(
67
80
  ({ index, type, hash, input, skip, timestamp }) => ({
@@ -86,10 +99,27 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
86
99
  strand.documentId,
87
100
  operations
88
101
  ));
89
- this.syncStatus.set(strand.driveId, result.status);
102
+
103
+ this.updateSyncStatus(strand.driveId, result.status);
104
+ this.emit('strandUpdate', strand);
90
105
  return result;
91
106
  }
92
107
 
108
+ private handleListenerError(
109
+ error: Error,
110
+ driveId: string,
111
+ listener: ListenerState
112
+ ) {
113
+ console.error(
114
+ `Listener ${listener.listener.label ?? listener.listener.listenerId} error: ${error.message}`
115
+ );
116
+ this.updateSyncStatus(
117
+ driveId,
118
+ error instanceof OperationError ? error.status : 'ERROR',
119
+ error
120
+ );
121
+ }
122
+
93
123
  private shouldSyncRemoteDrive(drive: DocumentDriveDocument) {
94
124
  return (
95
125
  drive.state.local.availableOffline &&
@@ -108,7 +138,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
108
138
 
109
139
  if (!driveTriggers) {
110
140
  driveTriggers = new Map();
111
- this.syncStatus.set(driveId, 'SYNCING');
141
+ this.updateSyncStatus(driveId, 'SYNCING');
112
142
  }
113
143
 
114
144
  if (PullResponderTransmitter.isPullResponderTrigger(trigger)) {
@@ -117,7 +147,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
117
147
  trigger,
118
148
  this.saveStrand.bind(this),
119
149
  error => {
120
- this.syncStatus.set(
150
+ this.updateSyncStatus(
121
151
  driveId,
122
152
  error instanceof OperationError
123
153
  ? error.status
@@ -127,7 +157,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
127
157
  acknowledgeSuccess => {}
128
158
  );
129
159
  driveTriggers.set(trigger.id, intervalId);
130
- this.triggerMap.set(trigger.id, driveTriggers);
160
+ this.triggerMap.set(driveId, driveTriggers);
131
161
  }
132
162
  }
133
163
  }
@@ -265,7 +295,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
265
295
  (filter.since !== undefined &&
266
296
  filter.since <= operation.timestamp) ||
267
297
  (filter.fromRevision !== undefined &&
268
- operation.index >= filter.fromRevision)
298
+ operation.index > filter.fromRevision)
269
299
  );
270
300
 
271
301
  return filteredOperations.map(operation => ({
@@ -456,6 +486,12 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
456
486
  }
457
487
 
458
488
  async deleteDocument(driveId: string, id: string) {
489
+ try {
490
+ const syncUnits = await this.getSynchronizationUnits(driveId, [id]);
491
+ this.listenerStateManager.removeSyncUnits(syncUnits);
492
+ } catch {
493
+ /* empty */
494
+ }
459
495
  return this.storage.deleteDocument(driveId, id);
460
496
  }
461
497
 
@@ -465,18 +501,27 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
465
501
  operations: Operation<A | BaseAction>[]
466
502
  ) {
467
503
  const operationsApplied: Operation<A | BaseAction>[] = [];
504
+ const operationsUpdated: Operation<A | BaseAction>[] = [];
468
505
  let document: T | undefined;
469
506
  const signals: SignalResult[] = [];
470
507
 
471
508
  // eslint-disable-next-line prefer-const
472
- let [operationsToApply, error] = this._validateOperations(
473
- operations,
474
- documentStorage
475
- );
509
+ let [operationsToApply, error, updatedOperations] =
510
+ this._validateOperations(operations, documentStorage);
511
+
512
+ const unregisteredOps = [
513
+ ...operationsToApply.map(operation => ({ operation, type: 'new' })),
514
+ ...updatedOperations.map(operation => ({
515
+ operation,
516
+ type: 'update'
517
+ }))
518
+ ].sort((a, b) => a.operation.index - b.operation.index);
476
519
 
477
520
  // retrieves the document's document model and
478
521
  // applies the operations using its reducer
479
- for (const operation of operationsToApply) {
522
+ for (const unregisteredOp of unregisteredOps) {
523
+ const { operation, type } = unregisteredOp;
524
+
480
525
  try {
481
526
  const {
482
527
  document: newDocument,
@@ -488,7 +533,13 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
488
533
  operation
489
534
  );
490
535
  document = newDocument;
491
- operationsApplied.push(appliedOperation);
536
+
537
+ if (type === 'new') {
538
+ operationsApplied.push(appliedOperation);
539
+ } else {
540
+ operationsUpdated.push(appliedOperation);
541
+ }
542
+
492
543
  signals.push(...signals);
493
544
  } catch (e) {
494
545
  if (!error) {
@@ -505,7 +556,18 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
505
556
  break;
506
557
  }
507
558
  }
508
- return { document, operationsApplied, signals, error } as const;
559
+
560
+ if (!document) {
561
+ document = this._buildDocument(documentStorage);
562
+ }
563
+
564
+ return {
565
+ document,
566
+ operationsApplied,
567
+ signals,
568
+ error,
569
+ operationsUpdated
570
+ } as const;
509
571
  }
510
572
 
511
573
  private _validateOperations<T extends Document, A extends Action>(
@@ -513,6 +575,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
513
575
  documentStorage: DocumentStorage<T>
514
576
  ) {
515
577
  const operationsToApply: Operation<A | BaseAction>[] = [];
578
+ const updatedOperations: Operation<A | BaseAction>[] = [];
516
579
  let error: OperationError | undefined;
517
580
 
518
581
  // sort operations so from smaller index to biggest
@@ -525,7 +588,17 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
525
588
  .slice(0, i);
526
589
  const scopeOperations = documentStorage.operations[op.scope];
527
590
 
528
- const nextIndex = scopeOperations.length + pastOperations.length;
591
+ // get latest operation
592
+ const ops = [...scopeOperations, ...pastOperations];
593
+ const latestOperation = ops.slice().pop();
594
+
595
+ const noopUpdate = isNoopUpdate(op, latestOperation);
596
+
597
+ let nextIndex = scopeOperations.length + pastOperations.length;
598
+ if (noopUpdate) {
599
+ nextIndex = nextIndex - 1;
600
+ }
601
+
529
602
  if (op.index > nextIndex) {
530
603
  error = new OperationError(
531
604
  'MISSING',
@@ -534,39 +607,54 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
534
607
  );
535
608
  continue;
536
609
  } else if (op.index < nextIndex) {
537
- const existingOperation =
538
- scopeOperations.concat(pastOperations)[op.index];
610
+ const existingOperation = scopeOperations.find(
611
+ existingOperation => existingOperation.index === op.index
612
+ );
539
613
  if (existingOperation && existingOperation.hash !== op.hash) {
540
614
  error = new OperationError(
541
615
  'CONFLICT',
542
616
  op,
543
- `Conflicting operation on index ${op.index}`
617
+ `Conflicting operation on index ${op.index}`,
618
+ { existingOperation, newOperation: op }
544
619
  );
545
620
  continue;
546
621
  }
547
622
  } else {
548
- operationsToApply.push(op);
623
+ if (noopUpdate) {
624
+ updatedOperations.push(op);
625
+ } else {
626
+ operationsToApply.push(op);
627
+ }
549
628
  }
550
629
  }
551
630
 
552
- return [operationsToApply, error] as const;
631
+ return [operationsToApply, error, updatedOperations] as const;
553
632
  }
554
633
 
555
- private async _performOperation<T extends Document, A extends Action>(
556
- drive: string,
557
- documentStorage: DocumentStorage<T>,
558
- operation: Operation<A | BaseAction>
559
- ) {
634
+ private _buildDocument<T extends Document>(
635
+ documentStorage: DocumentStorage<T>
636
+ ): T {
560
637
  const documentModel = this._getDocumentModel(
561
638
  documentStorage.documentType
562
639
  );
563
- const document = baseUtils.replayDocument(
640
+ return baseUtils.replayDocument(
564
641
  documentStorage.initialState,
565
642
  documentStorage.operations,
566
643
  documentModel.reducer,
567
644
  undefined,
568
645
  documentStorage
569
646
  ) as T;
647
+ }
648
+
649
+ private async _performOperation<T extends Document, A extends Action>(
650
+ drive: string,
651
+ documentStorage: DocumentStorage<T>,
652
+ operation: Operation<A | BaseAction>
653
+ ) {
654
+ const documentModel = this._getDocumentModel(
655
+ documentStorage.documentType
656
+ );
657
+ const document = this._buildDocument(documentStorage);
570
658
 
571
659
  const signalResults: SignalResult[] = [];
572
660
  let newDocument = document;
@@ -612,10 +700,10 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
612
700
  );
613
701
  }
614
702
 
615
- const results = await Promise.all(
616
- operationSignals.map(handler => handler())
617
- );
618
- signalResults.push(...results);
703
+ for (const signalHandler of operationSignals) {
704
+ const result = await signalHandler();
705
+ signalResults.push(result);
706
+ }
619
707
 
620
708
  return {
621
709
  document: newDocument,
@@ -634,6 +722,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
634
722
 
635
723
  let document: Document | undefined;
636
724
  const operationsApplied: Operation[] = [];
725
+ const updatedOperations: Operation[] = [];
637
726
  const signals: SignalResult[] = [];
638
727
  let error: Error | undefined;
639
728
 
@@ -647,7 +736,10 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
647
736
  );
648
737
 
649
738
  document = result.document;
739
+
650
740
  operationsApplied.push(...result.operationsApplied);
741
+ updatedOperations.push(...result.operationsUpdated);
742
+
651
743
  signals.push(...result.signals);
652
744
  error = result.error;
653
745
 
@@ -656,15 +748,21 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
656
748
  }
657
749
 
658
750
  // saves the applied operations to storage
659
- await this.storage.addDocumentOperations(
660
- drive,
661
- id,
662
- operationsApplied,
663
- document
664
- );
751
+ if (operationsApplied.length > 0 || updatedOperations.length > 0) {
752
+ await this.storage.addDocumentOperations(
753
+ drive,
754
+ id,
755
+ operationsApplied,
756
+ document,
757
+ updatedOperations
758
+ );
759
+ }
665
760
 
666
761
  // gets all the different scopes and branches combinations from the operations
667
- const { scopes, branches } = operationsApplied.reduce(
762
+ const { scopes, branches } = [
763
+ ...operationsApplied,
764
+ ...updatedOperations
765
+ ].reduce(
668
766
  (acc, operation) => {
669
767
  if (!acc.scopes.includes(operation.scope)) {
670
768
  acc.scopes.push(operation.scope);
@@ -682,12 +780,21 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
682
780
  );
683
781
  // update listener cache
684
782
  for (const syncUnit of syncUnits) {
685
- await this.listenerStateManager.updateSynchronizationRevision(
686
- drive,
687
- syncUnit.syncId,
688
- syncUnit.revision,
689
- syncUnit.lastUpdated
690
- );
783
+ this.listenerStateManager
784
+ .updateSynchronizationRevision(
785
+ drive,
786
+ syncUnit.syncId,
787
+ syncUnit.revision,
788
+ syncUnit.lastUpdated,
789
+ this.handleListenerError.bind(this)
790
+ )
791
+ .catch(error => {
792
+ console.error(
793
+ 'Non handled error updating sync revision',
794
+ error
795
+ );
796
+ this.updateSyncStatus(drive, 'ERROR', error as Error);
797
+ });
691
798
  }
692
799
 
693
800
  // after applying all the valid operations,throws
@@ -759,11 +866,13 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
759
866
  }
760
867
 
761
868
  // saves the applied operations to storage
762
- await this.storage.addDriveOperations(
763
- drive,
764
- operationsApplied,
765
- document
766
- );
869
+ if (operationsApplied.length > 0) {
870
+ await this.storage.addDriveOperations(
871
+ drive,
872
+ operationsApplied,
873
+ document
874
+ );
875
+ }
767
876
 
768
877
  for (const operation of operationsApplied) {
769
878
  if (operation.type === 'ADD_LISTENER') {
@@ -802,12 +911,21 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
802
911
  .slice()
803
912
  .pop();
804
913
  if (lastOperation) {
805
- await this.listenerStateManager.updateSynchronizationRevision(
806
- drive,
807
- '0',
808
- lastOperation.index,
809
- lastOperation.timestamp
810
- );
914
+ this.listenerStateManager
915
+ .updateSynchronizationRevision(
916
+ drive,
917
+ '0',
918
+ lastOperation.index,
919
+ lastOperation.timestamp,
920
+ this.handleListenerError.bind(this)
921
+ )
922
+ .catch(error => {
923
+ console.error(
924
+ 'Non handled error updating sync revision',
925
+ error
926
+ );
927
+ this.updateSyncStatus(drive, 'ERROR', error as Error);
928
+ });
811
929
  }
812
930
 
813
931
  if (this.shouldSyncRemoteDrive(document)) {
@@ -863,4 +981,15 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
863
981
  }
864
982
  return status;
865
983
  }
984
+
985
+ on<K extends keyof DriveEvents>(event: K, cb: DriveEvents[K]): Unsubscribe {
986
+ return this.emitter.on(event, cb);
987
+ }
988
+
989
+ protected emit<K extends keyof DriveEvents>(
990
+ event: K,
991
+ ...args: Parameters<DriveEvents[K]>
992
+ ): void {
993
+ return this.emitter.emit(event, ...args);
994
+ }
866
995
  }
@@ -9,6 +9,7 @@ import {
9
9
  ErrorStatus,
10
10
  Listener,
11
11
  ListenerState,
12
+ OperationUpdate,
12
13
  StrandUpdate,
13
14
  SynchronizationUnit
14
15
  } from '../types';
@@ -120,7 +121,12 @@ export class ListenerManager extends BaseListenerManager {
120
121
  driveId: string,
121
122
  syncId: string,
122
123
  syncRev: number,
123
- lastUpdated: string
124
+ lastUpdated: string,
125
+ onError?: (
126
+ error: Error,
127
+ driveId: string,
128
+ listener: ListenerState
129
+ ) => void
124
130
  ) {
125
131
  const drive = this.listenerState.get(driveId);
126
132
  if (!drive) {
@@ -148,7 +154,7 @@ export class ListenerManager extends BaseListenerManager {
148
154
  }
149
155
 
150
156
  if (newRevision) {
151
- return this.triggerUpdate();
157
+ return this.triggerUpdate(onError);
152
158
  }
153
159
  }
154
160
 
@@ -202,6 +208,19 @@ export class ListenerManager extends BaseListenerManager {
202
208
  }
203
209
  }
204
210
 
211
+ removeSyncUnits(syncUnits: SynchronizationUnit[]) {
212
+ for (const [driveId, drive] of this.listenerState) {
213
+ const syncIds = syncUnits
214
+ .filter(s => s.driveId === driveId)
215
+ .map(s => s.syncId);
216
+ for (const [, listenerState] of drive) {
217
+ listenerState.syncUnits = listenerState.syncUnits.filter(
218
+ s => !syncIds.includes(s.syncId)
219
+ );
220
+ }
221
+ }
222
+ }
223
+
205
224
  async updateListenerRevision(
206
225
  listenerId: string,
207
226
  driveId: string,
@@ -225,7 +244,13 @@ export class ListenerManager extends BaseListenerManager {
225
244
  }
226
245
  }
227
246
 
228
- async triggerUpdate() {
247
+ async triggerUpdate(
248
+ onError?: (
249
+ error: Error,
250
+ driveId: string,
251
+ listener: ListenerState
252
+ ) => void
253
+ ) {
229
254
  for (const [driveId, drive] of this.listenerState) {
230
255
  for (const [id, listener] of drive) {
231
256
  const transmitter = await this.getTransmitter(driveId, id);
@@ -248,13 +273,19 @@ export class ListenerManager extends BaseListenerManager {
248
273
  continue;
249
274
  }
250
275
 
251
- const opData = await this.drive.getOperationData(
252
- driveId,
253
- syncId,
254
- {
255
- fromRevision: listenerRev
256
- }
257
- );
276
+ const opData: OperationUpdate[] = [];
277
+ try {
278
+ const data = await this.drive.getOperationData(
279
+ driveId,
280
+ syncId,
281
+ {
282
+ fromRevision: listenerRev
283
+ }
284
+ );
285
+ opData.push(...data);
286
+ } catch (e) {
287
+ console.error(e);
288
+ }
258
289
 
259
290
  if (!opData.length) {
260
291
  continue;
@@ -309,9 +340,9 @@ export class ListenerManager extends BaseListenerManager {
309
340
  listener.listenerStatus = 'SUCCESS';
310
341
  } catch (e) {
311
342
  // TODO: Handle error based on listener params (blocking, retry, etc)
343
+ onError?.(e as Error, driveId, listener);
312
344
  listener.listenerStatus =
313
345
  e instanceof OperationError ? e.status : 'ERROR';
314
- throw e;
315
346
  }
316
347
  }
317
348
  }
@@ -76,6 +76,11 @@ export class PullResponderTransmitter implements ITransmitter {
76
76
  fromRevision: entry.listenerRev
77
77
  }
78
78
  );
79
+
80
+ if (!operations.length) {
81
+ continue;
82
+ }
83
+
79
84
  strands.push({
80
85
  driveId,
81
86
  documentId,
@@ -94,12 +99,18 @@ export class PullResponderTransmitter implements ITransmitter {
94
99
  revisions: ListenerRevision[]
95
100
  ): Promise<boolean> {
96
101
  const listener = this.manager.getListener(driveId, listenerId);
102
+
97
103
  let success = true;
98
104
  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) {
105
+ const syncUnit = listener.syncUnits.find(
106
+ s =>
107
+ s.scope === revision.scope &&
108
+ s.branch === revision.branch &&
109
+ s.driveId === revision.driveId &&
110
+ s.documentId == revision.documentId
111
+ );
112
+ if (!syncUnit) {
113
+ console.log('Sync unit not found', revision);
103
114
  success = false;
104
115
  continue;
105
116
  }
@@ -107,7 +118,7 @@ export class PullResponderTransmitter implements ITransmitter {
107
118
  await this.manager.updateListenerRevision(
108
119
  listenerId,
109
120
  driveId,
110
- syncId,
121
+ syncUnit.syncId,
111
122
  revision.revision
112
123
  );
113
124
  }
@@ -235,6 +246,12 @@ export class PullResponderTransmitter implements ITransmitter {
235
246
  listenerId
236
247
  // since ?
237
248
  );
249
+
250
+ // if there are no new strands then do nothing
251
+ if (!strands.length) {
252
+ return;
253
+ }
254
+
238
255
  const listenerRevisions: ListenerRevision[] = [];
239
256
 
240
257
  for (const strand of strands) {
@@ -15,6 +15,7 @@ import type {
15
15
  Signal,
16
16
  State
17
17
  } from 'document-model/document';
18
+ import { Unsubscribe } from 'nanoevents';
18
19
  import { OperationError } from './error';
19
20
  import { ITransmitter } from './listener/transmitter/types';
20
21
 
@@ -114,6 +115,11 @@ export type StrandUpdate = {
114
115
 
115
116
  export type SyncStatus = 'SYNCING' | UpdateStatus;
116
117
 
118
+ export interface DriveEvents {
119
+ syncStatus: (driveId: string, status: SyncStatus, error?: Error) => void;
120
+ strandUpdate: (update: StrandUpdate) => void;
121
+ }
122
+
117
123
  export abstract class BaseDocumentDriveServer {
118
124
  /** Public methods **/
119
125
  abstract getDrives(): Promise<string[]>;
@@ -132,12 +138,12 @@ export abstract class BaseDocumentDriveServer {
132
138
  drive: string,
133
139
  id: string,
134
140
  operation: Operation
135
- ): Promise<IOperationResult<Document>>;
141
+ ): Promise<IOperationResult>;
136
142
  abstract addOperations(
137
143
  drive: string,
138
144
  id: string,
139
145
  operations: Operation[]
140
- ): Promise<IOperationResult<Document>>;
146
+ ): Promise<IOperationResult>;
141
147
 
142
148
  abstract addDriveOperation(
143
149
  drive: string,
@@ -183,12 +189,23 @@ export abstract class BaseDocumentDriveServer {
183
189
  driveId: string,
184
190
  listenerId: string
185
191
  ): Promise<ITransmitter | undefined>;
192
+
193
+ /** Event methods **/
194
+ protected abstract emit<K extends keyof DriveEvents>(
195
+ this: this,
196
+ event: K,
197
+ ...args: Parameters<DriveEvents[K]>
198
+ ): void;
199
+ abstract on<K extends keyof DriveEvents>(
200
+ this: this,
201
+ event: K,
202
+ cb: DriveEvents[K]
203
+ ): Unsubscribe;
186
204
  }
187
205
 
188
206
  export abstract class BaseListenerManager {
189
207
  protected drive: BaseDocumentDriveServer;
190
- protected listenerState: Map<string, Map<string, ListenerState>> =
191
- new Map();
208
+ protected listenerState = new Map<string, Map<string, ListenerState>>();
192
209
  protected transmitters: Record<
193
210
  DocumentDriveState['id'],
194
211
  Record<Listener['listenerId'], ITransmitter>
@@ -196,7 +213,7 @@ export abstract class BaseListenerManager {
196
213
 
197
214
  constructor(
198
215
  drive: BaseDocumentDriveServer,
199
- listenerState: Map<string, Map<string, ListenerState>> = new Map()
216
+ listenerState = new Map<string, Map<string, ListenerState>>()
200
217
  ) {
201
218
  this.drive = drive;
202
219
  this.listenerState = listenerState;
@@ -5,7 +5,7 @@ import {
5
5
  DocumentHeader,
6
6
  Operation
7
7
  } from 'document-model/document';
8
- import { mergeOperations } from '..';
8
+ import { applyUpdatedOperations, mergeOperations } from '..';
9
9
  import { DocumentDriveStorage, DocumentStorage, IDriveStorage } from './types';
10
10
 
11
11
  export class BrowserStorage implements IDriveStorage {
@@ -57,7 +57,8 @@ export class BrowserStorage implements IDriveStorage {
57
57
  drive: string,
58
58
  id: string,
59
59
  operations: Operation[],
60
- header: DocumentHeader
60
+ header: DocumentHeader,
61
+ updatedOperations: Operation[] = []
61
62
  ): Promise<void> {
62
63
  const document = await this.getDocument(drive, id);
63
64
  if (!document) {
@@ -69,12 +70,17 @@ export class BrowserStorage implements IDriveStorage {
69
70
  operations
70
71
  );
71
72
 
73
+ const mergedUpdatedOperations = applyUpdatedOperations(
74
+ mergedOperations,
75
+ updatedOperations
76
+ );
77
+
72
78
  await (
73
79
  await this.db
74
80
  ).setItem(this.buildKey(drive, id), {
75
81
  ...document,
76
82
  ...header,
77
- operations: mergedOperations
83
+ operations: mergedUpdatedOperations
78
84
  });
79
85
  }
80
86
 
@@ -12,7 +12,7 @@ import fs from 'fs/promises';
12
12
  import stringify from 'json-stringify-deterministic';
13
13
  import path from 'path';
14
14
  import sanitize from 'sanitize-filename';
15
- import { mergeOperations } from '..';
15
+ import { applyUpdatedOperations, mergeOperations } from '..';
16
16
  import { DocumentDriveStorage, DocumentStorage, IDriveStorage } from './types';
17
17
 
18
18
  type FSError = {
@@ -104,7 +104,8 @@ export class FilesystemStorage implements IDriveStorage {
104
104
  drive: string,
105
105
  id: string,
106
106
  operations: Operation[],
107
- header: DocumentHeader
107
+ header: DocumentHeader,
108
+ updatedOperations: Operation[] = []
108
109
  ) {
109
110
  const document = await this.getDocument(drive, id);
110
111
  if (!document) {
@@ -116,10 +117,15 @@ export class FilesystemStorage implements IDriveStorage {
116
117
  operations
117
118
  );
118
119
 
120
+ const mergedUpdatedOperations = applyUpdatedOperations(
121
+ mergedOperations,
122
+ updatedOperations
123
+ );
124
+
119
125
  this.createDocument(drive, id, {
120
126
  ...document,
121
127
  ...header,
122
- operations: mergedOperations
128
+ operations: mergedUpdatedOperations
123
129
  });
124
130
  }
125
131
 
@@ -5,7 +5,7 @@ import {
5
5
  DocumentHeader,
6
6
  Operation
7
7
  } from 'document-model/document';
8
- import { mergeOperations } from '..';
8
+ import { applyUpdatedOperations, mergeOperations } from '..';
9
9
  import { DocumentDriveStorage, DocumentStorage, IDriveStorage } from './types';
10
10
 
11
11
  export class MemoryStorage implements IDriveStorage {
@@ -67,7 +67,8 @@ export class MemoryStorage implements IDriveStorage {
67
67
  drive: string,
68
68
  id: string,
69
69
  operations: Operation[],
70
- header: DocumentHeader
70
+ header: DocumentHeader,
71
+ updatedOperations: Operation[] = []
71
72
  ): Promise<void> {
72
73
  const document = await this.getDocument(drive, id);
73
74
  if (!document) {
@@ -79,10 +80,15 @@ export class MemoryStorage implements IDriveStorage {
79
80
  operations
80
81
  );
81
82
 
83
+ const mergedUpdatedOperations = applyUpdatedOperations(
84
+ mergedOperations,
85
+ updatedOperations
86
+ );
87
+
82
88
  this.documents[drive]![id] = {
83
89
  ...document,
84
90
  ...header,
85
- operations: mergedOperations
91
+ operations: mergedUpdatedOperations
86
92
  };
87
93
  }
88
94
 
@@ -1,9 +1,9 @@
1
- import { type Prisma } from '@prisma/client';
1
+ import { PrismaClient, type Prisma } from '@prisma/client';
2
2
  import {
3
3
  DocumentDriveLocalState,
4
4
  DocumentDriveState
5
5
  } from 'document-model-libs/document-drive';
6
- import {
6
+ import type {
7
7
  DocumentHeader,
8
8
  ExtendedState,
9
9
  Operation,
@@ -12,16 +12,16 @@ import {
12
12
  import { DocumentDriveStorage, DocumentStorage, IDriveStorage } from './types';
13
13
 
14
14
  export class PrismaStorage implements IDriveStorage {
15
- private db: Prisma.TransactionClient;
15
+ private db: PrismaClient;
16
16
 
17
- constructor(db: Prisma.TransactionClient) {
17
+ constructor(db: PrismaClient) {
18
18
  this.db = db;
19
19
  }
20
20
 
21
21
  async createDrive(id: string, drive: DocumentDriveStorage): Promise<void> {
22
+ // drive for all drive documents
22
23
  await this.createDocument('drives', id, drive as DocumentStorage);
23
24
  }
24
-
25
25
  async addDriveOperations(
26
26
  id: string,
27
27
  operations: Operation[],
@@ -166,6 +166,9 @@ export class PrismaStorage implements IDriveStorage {
166
166
  },
167
167
  include: {
168
168
  operations: {
169
+ orderBy: {
170
+ index: 'asc'
171
+ },
169
172
  include: {
170
173
  attachments: true
171
174
  }
@@ -225,7 +228,7 @@ export class PrismaStorage implements IDriveStorage {
225
228
  scope: op.scope as OperationScope
226
229
  // attachments: fileRegistry
227
230
  })),
228
- revision: dbDoc.revision as Required<Record<OperationScope, number>>
231
+ revision: dbDoc.revision as Record<OperationScope, number>
229
232
  };
230
233
 
231
234
  return doc;
@@ -281,7 +284,12 @@ export class PrismaStorage implements IDriveStorage {
281
284
  }
282
285
 
283
286
  async getDrive(id: string) {
284
- return this.getDocument('drives', id) as Promise<DocumentDriveStorage>;
287
+ try {
288
+ const doc = await this.getDocument('drives', id);
289
+ return doc as DocumentDriveStorage;
290
+ } catch (e) {
291
+ throw new Error(`Drive with id ${id} not found`);
292
+ }
285
293
  }
286
294
 
287
295
  async deleteDrive(id: string) {
@@ -27,7 +27,8 @@ export interface IStorage {
27
27
  drive: string,
28
28
  id: string,
29
29
  operations: Operation[],
30
- header: DocumentHeader
30
+ header: DocumentHeader,
31
+ updatedOperations?: Operation[]
31
32
  ): Promise<void>;
32
33
  deleteDocument(drive: string, id: string): Promise<void>;
33
34
  }
@@ -38,3 +38,40 @@ export function generateUUID(): string {
38
38
  // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
39
39
  return crypto.randomUUID() as string;
40
40
  }
41
+
42
+ export function applyUpdatedOperations<A extends Action = Action>(
43
+ currentOperations: DocumentOperations<A>,
44
+ updatedOperations: Operation<A | BaseAction>[]
45
+ ): DocumentOperations<A> {
46
+ return updatedOperations.reduce(
47
+ (acc, curr) => {
48
+ const operations = acc[curr.scope] ?? [];
49
+ acc[curr.scope] = operations.map(op => {
50
+ return op.index === curr.index ? curr : op;
51
+ });
52
+ return acc;
53
+ },
54
+ { ...currentOperations }
55
+ );
56
+ }
57
+
58
+ export function isNoopUpdate(
59
+ operation: Operation,
60
+ latestOperation?: Operation
61
+ ) {
62
+ if (!latestOperation) {
63
+ return false;
64
+ }
65
+
66
+ const isNoopOp = operation.type === 'NOOP';
67
+ const isNoopLatestOp = latestOperation.type === 'NOOP';
68
+ const isSameIndexOp = operation.index === latestOperation.index;
69
+ const isSkipOpGreaterThanLatestOp = operation.skip > latestOperation.skip;
70
+
71
+ return (
72
+ isNoopOp &&
73
+ isNoopLatestOp &&
74
+ isSameIndexOp &&
75
+ isSkipOpGreaterThanLatestOp
76
+ );
77
+ }