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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "document-drive",
3
- "version": "1.0.0-alpha.32",
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.35",
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",
@@ -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
- generateUUID,
33
- isBefore,
34
- isDocumentDrive,
35
- isNoopUpdate
36
- } from '../utils';
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
- documentStorage: DocumentStorage<T>,
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
- // eslint-disable-next-line prefer-const
539
- let [operationsToApply, error, updatedOperations] =
540
- this._validateOperations(operations, documentStorage);
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 unregisteredOps = [
543
- ...operationsToApply.map(operation => ({ operation, type: 'new' })),
544
- ...updatedOperations.map(operation => ({
545
- operation,
546
- type: 'update'
547
- }))
548
- ].sort((a, b) => a.operation.index - b.operation.index);
549
-
550
- // retrieves the document's document model and
551
- // applies the operations using its reducer
552
- for (const unregisteredOp of unregisteredOps) {
553
- const { operation, type } = unregisteredOp;
554
-
555
- try {
556
- const {
557
- document: newDocument,
558
- signals,
559
- operation: appliedOperation
560
- } = await this._performOperation(
561
- drive,
562
- document ?? documentStorage,
563
- operation
564
- );
565
- document = newDocument;
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
- if (type === 'new') {
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
- signals.push(...signals);
574
- } catch (e) {
575
- if (!error) {
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
- operation,
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(newDocument, operation, signal => {
691
- let handler: (() => Promise<unknown>) | undefined = undefined;
692
- switch (signal.type) {
693
- case 'CREATE_CHILD_DOCUMENT':
694
- handler = () => this.createDocument(drive, signal.input);
695
- break;
696
- case 'DELETE_CHILD_DOCUMENT':
697
- handler = () => this.deleteDocument(drive, signal.input.id);
698
- break;
699
- case 'COPY_CHILD_DOCUMENT':
700
- handler = () =>
701
- this.getDocument(drive, signal.input.id).then(
702
- documentToCopy =>
703
- this.createDocument(drive, {
704
- id: signal.input.newId,
705
- documentType: documentToCopy.documentType,
706
- document: documentToCopy,
707
- synchronizationUnits:
708
- signal.input.synchronizationUnits
709
- })
710
- );
711
- break;
712
- }
713
- if (handler) {
714
- operationSignals.push(() =>
715
- handler().then(result => ({ signal, result }))
716
- );
717
- }
718
- }) as T;
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
- const appliedOperation =
721
- newDocument.operations[operation.scope][operation.index];
722
- if (!appliedOperation || appliedOperation.hash !== operation.hash) {
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} had different result`
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
+ }
@@ -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();