document-drive 1.0.0-alpha.32 → 1.0.0-alpha.34
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 +125 -143
- package/src/utils/document-helpers.ts +494 -0
- package/src/utils/logger.ts +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "document-drive",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.34",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./src/index.ts",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"./storage/memory": "./src/storage/memory.ts",
|
|
15
15
|
"./storage/prisma": "./src/storage/prisma.ts",
|
|
16
16
|
"./utils": "./src/utils/index.ts",
|
|
17
|
-
"./utils/graphql": "./src/utils/graphql.ts"
|
|
17
|
+
"./utils/graphql": "./src/utils/graphql.ts",
|
|
18
|
+
"./logger": "./src/utils/logger.ts"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
21
|
"./src"
|
|
@@ -54,7 +55,7 @@
|
|
|
54
55
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
|
55
56
|
"@typescript-eslint/parser": "^6.21.0",
|
|
56
57
|
"@vitest/coverage-v8": "^1.4.0",
|
|
57
|
-
"document-model": "^1.0.
|
|
58
|
+
"document-model": "^1.0.36",
|
|
58
59
|
"document-model-libs": "^1.24.0",
|
|
59
60
|
"eslint": "^8.57.0",
|
|
60
61
|
"eslint-config-prettier": "^9.1.0",
|
package/src/server/index.ts
CHANGED
|
@@ -28,19 +28,20 @@ import type {
|
|
|
28
28
|
DocumentStorage,
|
|
29
29
|
IDriveStorage
|
|
30
30
|
} from '../storage/types';
|
|
31
|
+
import { generateUUID, isBefore, isDocumentDrive } from '../utils';
|
|
31
32
|
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
attachBranch,
|
|
34
|
+
garbageCollect,
|
|
35
|
+
groupOperationsByScope,
|
|
36
|
+
merge,
|
|
37
|
+
precedes,
|
|
38
|
+
removeExistingOperations,
|
|
39
|
+
reshuffleByTimestampAndIndex,
|
|
40
|
+
sortOperations
|
|
41
|
+
} from '../utils/document-helpers';
|
|
37
42
|
import { requestPublicDrive } from '../utils/graphql';
|
|
38
43
|
import { logger } from '../utils/logger';
|
|
39
|
-
import {
|
|
40
|
-
ConflictOperationError,
|
|
41
|
-
MissingOperationError,
|
|
42
|
-
OperationError
|
|
43
|
-
} from './error';
|
|
44
|
+
import { OperationError } from './error';
|
|
44
45
|
import { ListenerManager } from './listener/manager';
|
|
45
46
|
import {
|
|
46
47
|
CancelPullLoop,
|
|
@@ -527,70 +528,90 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
527
528
|
|
|
528
529
|
async _processOperations<T extends Document, A extends Action>(
|
|
529
530
|
drive: string,
|
|
530
|
-
|
|
531
|
+
storageDocument: DocumentStorage<T>,
|
|
531
532
|
operations: Operation<A | BaseAction>[]
|
|
532
533
|
) {
|
|
533
534
|
const operationsApplied: Operation<A | BaseAction>[] = [];
|
|
534
535
|
const operationsUpdated: Operation<A | BaseAction>[] = [];
|
|
535
|
-
let document: T | undefined;
|
|
536
536
|
const signals: SignalResult[] = [];
|
|
537
537
|
|
|
538
|
-
|
|
539
|
-
let
|
|
540
|
-
|
|
538
|
+
let document: T = this._buildDocument(storageDocument);
|
|
539
|
+
let error: OperationError | undefined; // TODO: replace with an array of errors/consistency issues
|
|
540
|
+
const operationsByScope = groupOperationsByScope(operations);
|
|
541
541
|
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
542
|
+
for (const scope of Object.keys(operationsByScope)) {
|
|
543
|
+
const storageDocumentOperations =
|
|
544
|
+
storageDocument.operations[scope as OperationScope];
|
|
545
|
+
|
|
546
|
+
const branch = removeExistingOperations(
|
|
547
|
+
operationsByScope[scope as OperationScope] || [],
|
|
548
|
+
storageDocumentOperations
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
const trunk = garbageCollect(
|
|
552
|
+
sortOperations(storageDocumentOperations)
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
const [invertedTrunk, tail] = attachBranch(trunk, branch);
|
|
556
|
+
|
|
557
|
+
const newHistory =
|
|
558
|
+
tail.length < 1
|
|
559
|
+
? invertedTrunk
|
|
560
|
+
: merge(trunk, invertedTrunk, reshuffleByTimestampAndIndex);
|
|
561
|
+
|
|
562
|
+
const lastOriginalOperation = trunk[trunk.length - 1];
|
|
563
|
+
|
|
564
|
+
const newOperations = newHistory.filter(
|
|
565
|
+
op => trunk.length < 1 || precedes(trunk[trunk.length - 1]!, op)
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
const firstNewOperation = newOperations[0];
|
|
569
|
+
let updatedOperationIndex = -1;
|
|
570
|
+
|
|
571
|
+
if (lastOriginalOperation && firstNewOperation) {
|
|
572
|
+
if (lastOriginalOperation.index === firstNewOperation.index) {
|
|
573
|
+
if (lastOriginalOperation.skip >= firstNewOperation.skip) {
|
|
574
|
+
console.error(
|
|
575
|
+
'Unexpected firstNewOperation.skip lower than or equal to lastOriginalOperation.skip.'
|
|
576
|
+
);
|
|
577
|
+
}
|
|
566
578
|
|
|
567
|
-
|
|
568
|
-
operationsApplied.push(appliedOperation);
|
|
569
|
-
} else {
|
|
570
|
-
operationsUpdated.push(appliedOperation);
|
|
579
|
+
updatedOperationIndex = firstNewOperation.index;
|
|
571
580
|
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
for (const nextOperation of newOperations) {
|
|
584
|
+
try {
|
|
585
|
+
const appliedResult = await this._performOperation<T, A>(
|
|
586
|
+
drive,
|
|
587
|
+
document,
|
|
588
|
+
nextOperation
|
|
589
|
+
);
|
|
590
|
+
document = appliedResult.document;
|
|
591
|
+
signals.push(...appliedResult.signals);
|
|
572
592
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
593
|
+
if (nextOperation.index === updatedOperationIndex) {
|
|
594
|
+
operationsUpdated.push(...appliedResult.operation);
|
|
595
|
+
} else {
|
|
596
|
+
operationsApplied.push(...appliedResult.operation);
|
|
597
|
+
}
|
|
598
|
+
} catch (e) {
|
|
576
599
|
error =
|
|
577
600
|
e instanceof OperationError
|
|
578
601
|
? e
|
|
579
602
|
: new OperationError(
|
|
580
603
|
'ERROR',
|
|
581
|
-
|
|
604
|
+
nextOperation,
|
|
582
605
|
(e as Error).message,
|
|
583
606
|
(e as Error).cause
|
|
584
607
|
);
|
|
608
|
+
|
|
609
|
+
// TODO: don't break on errors...
|
|
610
|
+
break;
|
|
585
611
|
}
|
|
586
|
-
break;
|
|
587
612
|
}
|
|
588
613
|
}
|
|
589
614
|
|
|
590
|
-
if (!document) {
|
|
591
|
-
document = this._buildDocument(documentStorage);
|
|
592
|
-
}
|
|
593
|
-
|
|
594
615
|
return {
|
|
595
616
|
document,
|
|
596
617
|
operationsApplied,
|
|
@@ -600,64 +621,6 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
600
621
|
} as const;
|
|
601
622
|
}
|
|
602
623
|
|
|
603
|
-
private _validateOperations<T extends Document, A extends Action>(
|
|
604
|
-
operations: Operation<A | BaseAction>[],
|
|
605
|
-
documentStorage: DocumentStorage<T>
|
|
606
|
-
) {
|
|
607
|
-
const operationsToApply: Operation<A | BaseAction>[] = [];
|
|
608
|
-
const updatedOperations: Operation<A | BaseAction>[] = [];
|
|
609
|
-
let error: OperationError | undefined;
|
|
610
|
-
|
|
611
|
-
// sort operations so from smaller index to biggest
|
|
612
|
-
operations = operations.sort((a, b) => a.index - b.index);
|
|
613
|
-
|
|
614
|
-
for (let i = 0; i < operations.length; i++) {
|
|
615
|
-
const op = operations[i]!;
|
|
616
|
-
const pastOperations = operationsToApply
|
|
617
|
-
.filter(appliedOperation => appliedOperation.scope === op.scope)
|
|
618
|
-
.slice(0, i);
|
|
619
|
-
const scopeOperations = documentStorage.operations[op.scope];
|
|
620
|
-
|
|
621
|
-
// get latest operation
|
|
622
|
-
const ops = [...scopeOperations, ...pastOperations];
|
|
623
|
-
const latestOperation = ops.slice().pop();
|
|
624
|
-
|
|
625
|
-
const noopUpdate = isNoopUpdate(op, latestOperation);
|
|
626
|
-
|
|
627
|
-
let nextIndex = scopeOperations.length + pastOperations.length;
|
|
628
|
-
if (noopUpdate) {
|
|
629
|
-
nextIndex = nextIndex - 1;
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
if (op.index > nextIndex) {
|
|
633
|
-
error = new MissingOperationError(nextIndex, op);
|
|
634
|
-
continue;
|
|
635
|
-
} else if (op.index < nextIndex) {
|
|
636
|
-
const existingOperation = scopeOperations
|
|
637
|
-
.concat(pastOperations)
|
|
638
|
-
.find(
|
|
639
|
-
existingOperation =>
|
|
640
|
-
existingOperation.index === op.index
|
|
641
|
-
);
|
|
642
|
-
if (existingOperation && existingOperation.hash !== op.hash) {
|
|
643
|
-
error = new ConflictOperationError(existingOperation, op);
|
|
644
|
-
continue;
|
|
645
|
-
} else if (!existingOperation) {
|
|
646
|
-
error = new MissingOperationError(nextIndex, op);
|
|
647
|
-
continue;
|
|
648
|
-
}
|
|
649
|
-
} else {
|
|
650
|
-
if (noopUpdate) {
|
|
651
|
-
updatedOperations.push(op);
|
|
652
|
-
} else {
|
|
653
|
-
operationsToApply.push(op);
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
return [operationsToApply, error, updatedOperations] as const;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
624
|
private _buildDocument<T extends Document>(
|
|
662
625
|
documentStorage: DocumentStorage<T>
|
|
663
626
|
): T {
|
|
@@ -687,43 +650,62 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
687
650
|
let newDocument = document;
|
|
688
651
|
|
|
689
652
|
const operationSignals: (() => Promise<SignalResult>)[] = [];
|
|
690
|
-
newDocument = documentModel.reducer(
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
653
|
+
newDocument = documentModel.reducer(
|
|
654
|
+
newDocument,
|
|
655
|
+
operation,
|
|
656
|
+
signal => {
|
|
657
|
+
let handler: (() => Promise<unknown>) | undefined = undefined;
|
|
658
|
+
switch (signal.type) {
|
|
659
|
+
case 'CREATE_CHILD_DOCUMENT':
|
|
660
|
+
handler = () =>
|
|
661
|
+
this.createDocument(drive, signal.input);
|
|
662
|
+
break;
|
|
663
|
+
case 'DELETE_CHILD_DOCUMENT':
|
|
664
|
+
handler = () =>
|
|
665
|
+
this.deleteDocument(drive, signal.input.id);
|
|
666
|
+
break;
|
|
667
|
+
case 'COPY_CHILD_DOCUMENT':
|
|
668
|
+
handler = () =>
|
|
669
|
+
this.getDocument(drive, signal.input.id).then(
|
|
670
|
+
documentToCopy =>
|
|
671
|
+
this.createDocument(drive, {
|
|
672
|
+
id: signal.input.newId,
|
|
673
|
+
documentType:
|
|
674
|
+
documentToCopy.documentType,
|
|
675
|
+
document: documentToCopy,
|
|
676
|
+
synchronizationUnits:
|
|
677
|
+
signal.input.synchronizationUnits
|
|
678
|
+
})
|
|
679
|
+
);
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
if (handler) {
|
|
683
|
+
operationSignals.push(() =>
|
|
684
|
+
handler().then(result => ({ signal, result }))
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
},
|
|
688
|
+
{ skip: operation.skip }
|
|
689
|
+
) as T;
|
|
690
|
+
|
|
691
|
+
const appliedOperation = newDocument.operations[operation.scope].filter(
|
|
692
|
+
op => op.index == operation.index && op.skip == operation.skip
|
|
693
|
+
);
|
|
719
694
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
695
|
+
if (appliedOperation.length < 1) {
|
|
696
|
+
throw new OperationError(
|
|
697
|
+
'ERROR',
|
|
698
|
+
operation,
|
|
699
|
+
`Operation with index ${operation.index}:${operation.skip} was not applied.`
|
|
700
|
+
);
|
|
701
|
+
} else if (
|
|
702
|
+
operation.type !== 'NOOP' &&
|
|
703
|
+
appliedOperation[0]!.hash !== operation.hash
|
|
704
|
+
) {
|
|
723
705
|
throw new OperationError(
|
|
724
706
|
'CONFLICT',
|
|
725
707
|
operation,
|
|
726
|
-
`Operation with index ${operation.index}
|
|
708
|
+
`Operation with index ${operation.index}:${operation.skip} has unexpected result hash`
|
|
727
709
|
);
|
|
728
710
|
}
|
|
729
711
|
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { Operation, OperationScope } from 'document-model/document';
|
|
2
|
+
import stringify from 'json-stringify-deterministic';
|
|
3
|
+
|
|
4
|
+
type OperationIndex = {
|
|
5
|
+
index: number;
|
|
6
|
+
skip: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export enum IntegrityIssueType {
|
|
10
|
+
UNEXPECTED_INDEX = 'UNEXPECTED_INDEX'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export enum IntegrityIssueSubType {
|
|
14
|
+
DUPLICATED_INDEX = 'DUPLICATED_INDEX',
|
|
15
|
+
MISSING_INDEX = 'MISSING_INDEX'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type IntegrityIssue = {
|
|
19
|
+
operation: OperationIndex;
|
|
20
|
+
issue: IntegrityIssueType;
|
|
21
|
+
category: IntegrityIssueSubType;
|
|
22
|
+
message: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type Reshuffle = (
|
|
26
|
+
startIndex: OperationIndex,
|
|
27
|
+
opsA: Operation[],
|
|
28
|
+
opsB: Operation[]
|
|
29
|
+
) => Operation[];
|
|
30
|
+
|
|
31
|
+
export function checkCleanedOperationsIntegrity(
|
|
32
|
+
sortedOperations: Operation[]
|
|
33
|
+
): IntegrityIssue[] {
|
|
34
|
+
const result: IntegrityIssue[] = [];
|
|
35
|
+
|
|
36
|
+
// 1:1 1
|
|
37
|
+
// 0:0 0 -> 1:0 1 -> 2:0 -> 3:0 -> 4:0 -> 5:0
|
|
38
|
+
// 0:0 0 -> 2:1 1 -> 3:0 -> 4:0 -> 5:0
|
|
39
|
+
// 0:0 0 -> 3:2 1 -> 4:0 -> 5:0
|
|
40
|
+
// 0:0 0 -> 3:2 1 -> 5:1
|
|
41
|
+
|
|
42
|
+
// 0:3 (expected 0, got -3)
|
|
43
|
+
// 1:2 (expected 0, got -1)
|
|
44
|
+
// 0:0 -> 1:1
|
|
45
|
+
// 0:0 -> 2:2
|
|
46
|
+
// 0:0 -> 3:2 -> 5:2
|
|
47
|
+
|
|
48
|
+
let currentIndex = -1;
|
|
49
|
+
for (const nextOperation of sortedOperations) {
|
|
50
|
+
const nextIndex = nextOperation.index - nextOperation.skip;
|
|
51
|
+
|
|
52
|
+
if (nextIndex !== currentIndex + 1) {
|
|
53
|
+
result.push({
|
|
54
|
+
operation: {
|
|
55
|
+
index: nextOperation.index,
|
|
56
|
+
skip: nextOperation.skip
|
|
57
|
+
},
|
|
58
|
+
issue: IntegrityIssueType.UNEXPECTED_INDEX,
|
|
59
|
+
category:
|
|
60
|
+
nextIndex > currentIndex + 1
|
|
61
|
+
? IntegrityIssueSubType.MISSING_INDEX
|
|
62
|
+
: IntegrityIssueSubType.DUPLICATED_INDEX,
|
|
63
|
+
message: `Expected index ${currentIndex + 1} with skip 0 or equivalent, got index ${nextOperation.index} with skip ${nextOperation.skip}`
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
currentIndex = nextOperation.index;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// [] -> []
|
|
74
|
+
// [0:0] -> [0:0]
|
|
75
|
+
|
|
76
|
+
// 0:0 1:0 2:0 => 0:0 1:0 2:0, removals 0, no issues
|
|
77
|
+
// 0:0 1:1 2:0 => 1:1 2:0, removals 1, no issues
|
|
78
|
+
|
|
79
|
+
// 0:0 1:1 2:0 3:1 => 1:1 3:1, removals 2, no issues
|
|
80
|
+
// 0:0 1:1 2:0 3:3 => 3:3
|
|
81
|
+
|
|
82
|
+
// 1:1 2:0 3:0 => 1:1 2:0 3:0, removals 0, no issues
|
|
83
|
+
// 1:0 0:0 2:0 => 2:0, removals 2, issues [UNEXPECTED_INDEX, INDEX_OUT_OF_ORDER]
|
|
84
|
+
// 0:0 1:0 2:0 => 0:0 1:0 2:0, removals 0, no issues
|
|
85
|
+
// 0:0 1:0 2:0 => 0:0 1:0 2:0, removals 0, no issues
|
|
86
|
+
// 0:0 1:0 2:0 => 0:0 1:0 2:0, removals 0, no issues
|
|
87
|
+
|
|
88
|
+
export function garbageCollect(sortedOperations: Operation[]): Operation[] {
|
|
89
|
+
const result: Operation[] = [];
|
|
90
|
+
|
|
91
|
+
let i = sortedOperations.length - 1;
|
|
92
|
+
|
|
93
|
+
while (i > -1) {
|
|
94
|
+
result.unshift(sortedOperations[i]!);
|
|
95
|
+
const skipUntil =
|
|
96
|
+
(sortedOperations[i]?.index || 0) -
|
|
97
|
+
(sortedOperations[i]?.skip || 0) -
|
|
98
|
+
1;
|
|
99
|
+
|
|
100
|
+
let j = i - 1;
|
|
101
|
+
while (j > -1 && (sortedOperations[j]?.index || 0) > skipUntil) {
|
|
102
|
+
j--;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
i = j;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function addUndo(sortedOperations: Operation[]): Operation[] {
|
|
112
|
+
const operationsCopy = [...sortedOperations];
|
|
113
|
+
const latestOperation = operationsCopy[operationsCopy.length - 1];
|
|
114
|
+
|
|
115
|
+
if (!latestOperation) return operationsCopy;
|
|
116
|
+
|
|
117
|
+
if (latestOperation.type === 'NOOP') {
|
|
118
|
+
operationsCopy.push({
|
|
119
|
+
...latestOperation,
|
|
120
|
+
index: latestOperation.index,
|
|
121
|
+
type: 'NOOP',
|
|
122
|
+
skip: nextSkipNumber(sortedOperations)
|
|
123
|
+
});
|
|
124
|
+
} else {
|
|
125
|
+
operationsCopy.push({
|
|
126
|
+
type: 'NOOP',
|
|
127
|
+
index: latestOperation.index + 1,
|
|
128
|
+
timestamp: new Date().toISOString(),
|
|
129
|
+
input: {},
|
|
130
|
+
skip: 1,
|
|
131
|
+
scope: latestOperation.scope,
|
|
132
|
+
hash: latestOperation.hash
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return operationsCopy;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// [0:0 2:0 1:0 3:3 3:1] => [0:0 1:0 2:0 3:1 3:3]
|
|
140
|
+
// Sort by index _and_ skip number
|
|
141
|
+
export function sortOperations(operations: Operation[]): Operation[] {
|
|
142
|
+
return operations
|
|
143
|
+
.slice()
|
|
144
|
+
.sort((a, b) => a.skip - b.skip)
|
|
145
|
+
.sort((a, b) => a.index - b.index);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// [0:0, 1:0, 2:0, A3:0, A4:0, A5:0] + [0:0, 1:0, 2:0, B3:0, B4:2, B5:0]
|
|
149
|
+
// GC => [0:0, 1:0, 2:0, A3:0, A4:0, A5:0] + [0:0, 1:0, B4:2, B5:0]
|
|
150
|
+
// Split => [0:0, 1:0] + [2:0, A3:0, A4:0, A5:0] + [B4:2, B5:0]
|
|
151
|
+
// Reshuffle(6:4) => [6:4, 7:0, 8:0, 9:0, 10:0, 11:0]
|
|
152
|
+
// merge => [0:0, 1:0, 6:4, 7:0, 8:0, 9:0, 10:0, 11:0]
|
|
153
|
+
export const reshuffleByTimestamp: Reshuffle = (startIndex, opsA, opsB) => {
|
|
154
|
+
return [...opsA, ...opsB]
|
|
155
|
+
.sort(
|
|
156
|
+
(a, b) =>
|
|
157
|
+
new Date(a.timestamp).getTime() -
|
|
158
|
+
new Date(b.timestamp).getTime()
|
|
159
|
+
)
|
|
160
|
+
.map((op, i) => ({
|
|
161
|
+
...op,
|
|
162
|
+
index: startIndex.index + i,
|
|
163
|
+
skip: i === 0 ? startIndex.skip : 0
|
|
164
|
+
}));
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export const reshuffleByTimestampAndIndex: Reshuffle = (
|
|
168
|
+
startIndex,
|
|
169
|
+
opsA,
|
|
170
|
+
opsB
|
|
171
|
+
) => {
|
|
172
|
+
return [...opsA, ...opsB]
|
|
173
|
+
.sort(
|
|
174
|
+
(a, b) =>
|
|
175
|
+
new Date(a.timestamp).getTime() -
|
|
176
|
+
new Date(b.timestamp).getTime()
|
|
177
|
+
)
|
|
178
|
+
.sort((a, b) => a.index - b.index)
|
|
179
|
+
.map((op, i) => ({
|
|
180
|
+
...op,
|
|
181
|
+
index: startIndex.index + i,
|
|
182
|
+
skip: i === 0 ? startIndex.skip : 0
|
|
183
|
+
}));
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// TODO: implement better operation equality function
|
|
187
|
+
export function operationsAreEqual(op1: Operation, op2: Operation) {
|
|
188
|
+
return stringify(op1) === stringify(op2);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// [T0:0 T1:0 T2:0 T3:0] + [B4:0 B5:0] = [T0:0 T1:0 T2:0 T3:0 B4:0 B5:0]
|
|
192
|
+
// [T0:0 T1:0 T2:0 T3:0] + [B3:0 B4:0] = [T0:0 T1:0 T2:0 B3:0 B4:0]
|
|
193
|
+
// [T0:0 T1:0 T2:0 T3:0] + [B2:0 B3:0] = [T0:0 T1:0 B2:0 B3:0]
|
|
194
|
+
|
|
195
|
+
// [T0:0 T1:0 T2:0 T3:0] + [B4:0 B4:2] = [T0:0 T1:0 T2:0 T3:0 B4:0 B4:2]
|
|
196
|
+
// [T0:0 T1:0 T2:0 T3:0] + [B3:0 B3:2] = [T0:0 T1:0 T2:0 B3:0 B3:2]
|
|
197
|
+
// [T0:0 T1:0 T2:0 T3:0] + [B2:3 B3:0] = [T0:0 T1:0 B2:3 B3:0]
|
|
198
|
+
|
|
199
|
+
export function attachBranch(
|
|
200
|
+
trunk: Operation[],
|
|
201
|
+
newBranch: Operation[]
|
|
202
|
+
): [Operation[], Operation[]] {
|
|
203
|
+
const trunkCopy = garbageCollect(sortOperations(trunk.slice()));
|
|
204
|
+
const newOperations = garbageCollect(sortOperations(newBranch.slice()));
|
|
205
|
+
if (trunkCopy.length < 1) {
|
|
206
|
+
return [newOperations, []];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const result: Operation[] = [];
|
|
210
|
+
let enteredBranch = false;
|
|
211
|
+
|
|
212
|
+
while (newOperations.length > 0) {
|
|
213
|
+
const newOperationCandidate = newOperations[0]!;
|
|
214
|
+
|
|
215
|
+
let nextTrunkOperation = trunkCopy.shift();
|
|
216
|
+
while (
|
|
217
|
+
nextTrunkOperation &&
|
|
218
|
+
precedes(nextTrunkOperation, newOperationCandidate)
|
|
219
|
+
) {
|
|
220
|
+
result.push(nextTrunkOperation);
|
|
221
|
+
nextTrunkOperation = trunkCopy.shift();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!nextTrunkOperation) {
|
|
225
|
+
enteredBranch = true;
|
|
226
|
+
} else if (!enteredBranch) {
|
|
227
|
+
if (operationsAreEqual(nextTrunkOperation, newOperationCandidate)) {
|
|
228
|
+
newOperations.shift();
|
|
229
|
+
result.push(nextTrunkOperation);
|
|
230
|
+
} else {
|
|
231
|
+
trunkCopy.unshift(nextTrunkOperation);
|
|
232
|
+
enteredBranch = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (enteredBranch) {
|
|
237
|
+
let nextAppend = newOperations.shift();
|
|
238
|
+
while (nextAppend) {
|
|
239
|
+
result.push(nextAppend);
|
|
240
|
+
nextAppend = newOperations.shift();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!enteredBranch) {
|
|
246
|
+
let nextAppend = trunkCopy.shift();
|
|
247
|
+
while (nextAppend) {
|
|
248
|
+
result.push(nextAppend);
|
|
249
|
+
nextAppend = trunkCopy.shift();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return [garbageCollect(result), trunkCopy];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function precedes(op1: Operation, op2: Operation) {
|
|
257
|
+
return (
|
|
258
|
+
op1.index < op2.index ||
|
|
259
|
+
(op1.index === op2.index && op1.skip < op2.skip)
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function split(
|
|
264
|
+
sortedTargetOperations: Operation[],
|
|
265
|
+
sortedMergeOperations: Operation[]
|
|
266
|
+
): [Operation[], Operation[], Operation[]] {
|
|
267
|
+
const commonOperations: Operation[] = [];
|
|
268
|
+
const targetDiffOperations: Operation[] = [];
|
|
269
|
+
const mergeDiffOperations: Operation[] = [];
|
|
270
|
+
|
|
271
|
+
// get bigger array length
|
|
272
|
+
const maxLength = Math.max(
|
|
273
|
+
sortedTargetOperations.length,
|
|
274
|
+
sortedMergeOperations.length
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
let splitHappened = false;
|
|
278
|
+
for (let i = 0; i < maxLength; i++) {
|
|
279
|
+
const targetOperation = sortedTargetOperations[i];
|
|
280
|
+
const mergeOperation = sortedMergeOperations[i];
|
|
281
|
+
|
|
282
|
+
if (targetOperation && mergeOperation) {
|
|
283
|
+
if (
|
|
284
|
+
!splitHappened &&
|
|
285
|
+
operationsAreEqual(targetOperation, mergeOperation)
|
|
286
|
+
) {
|
|
287
|
+
commonOperations.push(targetOperation);
|
|
288
|
+
} else {
|
|
289
|
+
splitHappened = true;
|
|
290
|
+
targetDiffOperations.push(targetOperation);
|
|
291
|
+
mergeDiffOperations.push(mergeOperation);
|
|
292
|
+
}
|
|
293
|
+
} else if (targetOperation) {
|
|
294
|
+
targetDiffOperations.push(targetOperation);
|
|
295
|
+
} else if (mergeOperation) {
|
|
296
|
+
mergeDiffOperations.push(mergeOperation);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return [commonOperations, targetDiffOperations, mergeDiffOperations];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// [0:0, 1:0, 2:0, A3:0, A4:0, A5:0] + [0:0, 1:0, 2:0, B3:0, B4:2, B5:0]
|
|
304
|
+
// GC => [0:0, 1:0, 2:0, A3:0, A4:0, A5:0] + [0:0, 1:0, B4:2, B5:0]
|
|
305
|
+
// Split => [0:0, 1:0] + [2:0, A3:0, A4:0, A5:0] + [B4:2, B5:0]
|
|
306
|
+
// Reshuffle(6:4) => [6:4, 7:0, 8:0, 9:0, 10:0, 11:0]
|
|
307
|
+
// merge => [0:0, 1:0, 6:4, 7:0, 8:0, 9:0, 10:0, 11:0]
|
|
308
|
+
export function merge(
|
|
309
|
+
sortedTargetOperations: Operation[],
|
|
310
|
+
sortedMergeOperations: Operation[],
|
|
311
|
+
reshuffle: Reshuffle
|
|
312
|
+
): Operation[] {
|
|
313
|
+
const [_commonOperations, _targetOperations, _mergeOperations] = split(
|
|
314
|
+
garbageCollect(sortedTargetOperations),
|
|
315
|
+
garbageCollect(sortedMergeOperations)
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const maxCommonIndex = getMaxIndex(_commonOperations);
|
|
319
|
+
const nextIndex =
|
|
320
|
+
1 +
|
|
321
|
+
Math.max(
|
|
322
|
+
maxCommonIndex,
|
|
323
|
+
getMaxIndex(_targetOperations),
|
|
324
|
+
getMaxIndex(_mergeOperations)
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const newOperationHistory = reshuffle(
|
|
328
|
+
{
|
|
329
|
+
index: nextIndex,
|
|
330
|
+
skip: nextIndex - (maxCommonIndex + 1)
|
|
331
|
+
},
|
|
332
|
+
_targetOperations,
|
|
333
|
+
_mergeOperations
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
return _commonOperations.concat(newOperationHistory);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function getMaxIndex(sortedOperations: Operation[]) {
|
|
340
|
+
const lastElement = sortedOperations[sortedOperations.length - 1];
|
|
341
|
+
if (!lastElement) {
|
|
342
|
+
return -1;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return lastElement.index;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// [] => -1
|
|
349
|
+
// [0:0] => -1
|
|
350
|
+
// [0:0 1:0] => 1
|
|
351
|
+
// [0:0 1:1] => -1
|
|
352
|
+
// [1:1] => -1
|
|
353
|
+
// [0:0 1:0 2:0] => 1
|
|
354
|
+
// [0:0 1:0 2:0 2:1] => 2
|
|
355
|
+
// [0:0 1:0 2:0 2:1 2:2] => -1
|
|
356
|
+
// [0:0 1:1 2:0] => 2
|
|
357
|
+
// [0:0 1:1 2:2] => -1
|
|
358
|
+
// [0:0 1:1 2:0 3:0] => 1
|
|
359
|
+
// [0:0 1:1 2:0 3:1] => 3
|
|
360
|
+
// [0:0 1:1 2:0 3:3] => -1
|
|
361
|
+
// [50:50 100:50 150:50 151:0 152:0 153:0 154:3] => 53
|
|
362
|
+
|
|
363
|
+
export function nextSkipNumber(sortedOperations: Operation[]): number {
|
|
364
|
+
if (sortedOperations.length < 1) {
|
|
365
|
+
return -1;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const cleanedOperations = garbageCollect(sortedOperations);
|
|
369
|
+
|
|
370
|
+
let nextSkip =
|
|
371
|
+
(cleanedOperations[cleanedOperations.length - 1]?.skip || 0) + 1;
|
|
372
|
+
|
|
373
|
+
if (cleanedOperations.length > 1) {
|
|
374
|
+
nextSkip += cleanedOperations[cleanedOperations.length - 2]?.skip || 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return (cleanedOperations[cleanedOperations.length - 1]?.index || -1) <
|
|
378
|
+
nextSkip
|
|
379
|
+
? -1
|
|
380
|
+
: nextSkip;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export const checkOperationsIntegrity = (
|
|
384
|
+
operations: Operation[]
|
|
385
|
+
): IntegrityIssue[] => {
|
|
386
|
+
return checkCleanedOperationsIntegrity(
|
|
387
|
+
garbageCollect(sortOperations(operations))
|
|
388
|
+
);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
export type OperationsByScope = Partial<Record<OperationScope, Operation[]>>;
|
|
392
|
+
|
|
393
|
+
export const groupOperationsByScope = (
|
|
394
|
+
operations: Operation[]
|
|
395
|
+
): OperationsByScope => {
|
|
396
|
+
const result = operations.reduce<OperationsByScope>((acc, operation) => {
|
|
397
|
+
if (!acc[operation.scope]) {
|
|
398
|
+
acc[operation.scope] = [];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
acc[operation.scope]?.push(operation);
|
|
402
|
+
|
|
403
|
+
return acc;
|
|
404
|
+
}, {});
|
|
405
|
+
|
|
406
|
+
return result;
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
type PrepareOperationsResult = {
|
|
410
|
+
validOperations: Operation[];
|
|
411
|
+
invalidOperations: Operation[];
|
|
412
|
+
duplicatedOperations: Operation[];
|
|
413
|
+
integrityIssues: IntegrityIssue[];
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
export const prepareOperations = (
|
|
417
|
+
operationsHistory: Operation[],
|
|
418
|
+
newOperations: Operation[]
|
|
419
|
+
): PrepareOperationsResult => {
|
|
420
|
+
const result: PrepareOperationsResult = {
|
|
421
|
+
integrityIssues: [],
|
|
422
|
+
validOperations: [],
|
|
423
|
+
invalidOperations: [],
|
|
424
|
+
duplicatedOperations: []
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const sortedOperationsHistory = sortOperations(operationsHistory);
|
|
428
|
+
const sortedOperations = sortOperations(newOperations);
|
|
429
|
+
|
|
430
|
+
const integrityErrors = checkCleanedOperationsIntegrity([
|
|
431
|
+
...sortedOperationsHistory,
|
|
432
|
+
...sortedOperations
|
|
433
|
+
]);
|
|
434
|
+
|
|
435
|
+
const missingIndexErrors = integrityErrors.filter(
|
|
436
|
+
integrityIssue =>
|
|
437
|
+
integrityIssue.category === IntegrityIssueSubType.MISSING_INDEX
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
// get the integrity error with the lowest index operation
|
|
441
|
+
const firstMissingIndexOperation = [...missingIndexErrors]
|
|
442
|
+
.sort((a, b) => b.operation.index - a.operation.index)
|
|
443
|
+
.pop()?.operation;
|
|
444
|
+
|
|
445
|
+
for (const newOperation of sortedOperations) {
|
|
446
|
+
// Operation is missing index or it follows an operation that is missing index
|
|
447
|
+
if (
|
|
448
|
+
firstMissingIndexOperation &&
|
|
449
|
+
newOperation.index >= firstMissingIndexOperation.index
|
|
450
|
+
) {
|
|
451
|
+
result.invalidOperations.push(newOperation);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// check if operation is duplicated
|
|
456
|
+
const isDuplicatedOperation = integrityErrors.some(integrityError => {
|
|
457
|
+
return (
|
|
458
|
+
integrityError.operation.index === newOperation.index &&
|
|
459
|
+
integrityError.operation.skip === newOperation.skip &&
|
|
460
|
+
integrityError.category ===
|
|
461
|
+
IntegrityIssueSubType.DUPLICATED_INDEX
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// add to duplicated operations if it is duplicated
|
|
466
|
+
if (isDuplicatedOperation) {
|
|
467
|
+
result.duplicatedOperations.push(newOperation);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// otherwise, add to valid operations
|
|
472
|
+
result.validOperations.push(newOperation);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
result.integrityIssues.push(...integrityErrors);
|
|
476
|
+
return result;
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
export function removeExistingOperations(
|
|
480
|
+
newOperations: Operation[],
|
|
481
|
+
operationsHistory: Operation[]
|
|
482
|
+
): Operation[] {
|
|
483
|
+
return newOperations.filter(newOperation => {
|
|
484
|
+
return !operationsHistory.some(historyOperation => {
|
|
485
|
+
return (
|
|
486
|
+
newOperation.index === historyOperation.index &&
|
|
487
|
+
newOperation.skip === historyOperation.skip &&
|
|
488
|
+
newOperation.scope === historyOperation.scope &&
|
|
489
|
+
newOperation.hash === historyOperation.hash &&
|
|
490
|
+
newOperation.type === historyOperation.type
|
|
491
|
+
);
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
}
|
package/src/utils/logger.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
export type ILogger = Pick<Console, 'log' | 'info' | 'warn' | 'error'>;
|
|
2
|
+
export type ILogger = Pick<Console, 'log' | 'info' | 'warn' | 'error' | 'debug' | 'trace'>;
|
|
3
3
|
class Logger implements ILogger {
|
|
4
4
|
#logger: ILogger = console;
|
|
5
5
|
|
|
@@ -11,18 +11,31 @@ class Logger implements ILogger {
|
|
|
11
11
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
12
12
|
return this.#logger.log(...data);
|
|
13
13
|
}
|
|
14
|
+
|
|
14
15
|
info(...data: any[]): void {
|
|
15
16
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
16
17
|
return this.#logger.info(...data);
|
|
17
18
|
}
|
|
19
|
+
|
|
18
20
|
warn(...data: any[]): void {
|
|
19
21
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
20
22
|
return this.#logger.warn(...data);
|
|
21
23
|
}
|
|
24
|
+
|
|
22
25
|
error(...data: any[]): void {
|
|
23
26
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
24
27
|
return this.#logger.error(...data);
|
|
25
28
|
}
|
|
29
|
+
|
|
30
|
+
debug(...data: any[]): void {
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
32
|
+
return this.#logger.debug(...data);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
trace(...data: any[]): void {
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
37
|
+
return this.#logger.trace(...data);
|
|
38
|
+
}
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
const loggerInstance = new Logger();
|