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 +4 -3
- package/src/server/index.ts +185 -56
- package/src/server/listener/manager.ts +42 -11
- package/src/server/listener/transmitter/pull-responder.ts +22 -5
- package/src/server/types.ts +22 -5
- package/src/storage/browser.ts +9 -3
- package/src/storage/filesystem.ts +9 -3
- package/src/storage/memory.ts +9 -3
- package/src/storage/prisma.ts +15 -7
- package/src/storage/types.ts +2 -1
- package/src/utils/index.ts +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "document-drive",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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.
|
|
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",
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
|
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] =
|
|
473
|
-
operations,
|
|
474
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
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
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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 } =
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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 =
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
100
|
-
s =>
|
|
101
|
-
|
|
102
|
-
|
|
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) {
|
package/src/server/types.ts
CHANGED
|
@@ -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
|
|
141
|
+
): Promise<IOperationResult>;
|
|
136
142
|
abstract addOperations(
|
|
137
143
|
drive: string,
|
|
138
144
|
id: string,
|
|
139
145
|
operations: Operation[]
|
|
140
|
-
): Promise<IOperationResult
|
|
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
|
|
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
|
|
216
|
+
listenerState = new Map<string, Map<string, ListenerState>>()
|
|
200
217
|
) {
|
|
201
218
|
this.drive = drive;
|
|
202
219
|
this.listenerState = listenerState;
|
package/src/storage/browser.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
128
|
+
operations: mergedUpdatedOperations
|
|
123
129
|
});
|
|
124
130
|
}
|
|
125
131
|
|
package/src/storage/memory.ts
CHANGED
|
@@ -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:
|
|
91
|
+
operations: mergedUpdatedOperations
|
|
86
92
|
};
|
|
87
93
|
}
|
|
88
94
|
|
package/src/storage/prisma.ts
CHANGED
|
@@ -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:
|
|
15
|
+
private db: PrismaClient;
|
|
16
16
|
|
|
17
|
-
constructor(db:
|
|
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
|
|
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
|
-
|
|
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) {
|
package/src/storage/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
+
}
|