document-drive 1.0.0-alpha.23 → 1.0.0-alpha.25
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 +6 -5
- package/src/server/error.ts +17 -0
- package/src/server/index.ts +114 -69
- package/src/storage/browser.ts +6 -5
- package/src/storage/prisma.ts +176 -42
- package/src/storage/sequelize.ts +65 -44
- package/src/storage/types.ts +17 -0
- package/src/utils/index.ts +19 -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.25",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./src/index.ts",
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"test:watch": "vitest watch"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@prisma/client": "5.
|
|
33
|
-
"document-model": "^1.0.
|
|
34
|
-
"document-model-libs": "^1.1
|
|
32
|
+
"@prisma/client": "5.11.0",
|
|
33
|
+
"document-model": "^1.0.35",
|
|
34
|
+
"document-model-libs": "^1.17.1",
|
|
35
35
|
"localforage": "^1.10.0",
|
|
36
36
|
"sequelize": "^6.35.2",
|
|
37
37
|
"sqlite3": "^5.1.7"
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@commitlint/cli": "^18.6.1",
|
|
48
48
|
"@commitlint/config-conventional": "^18.6.2",
|
|
49
|
-
"@prisma/client": "5.
|
|
49
|
+
"@prisma/client": "5.11.0",
|
|
50
50
|
"@semantic-release/changelog": "^6.0.3",
|
|
51
51
|
"@semantic-release/git": "^10.0.1",
|
|
52
52
|
"@total-typescript/ts-reset": "^0.5.1",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"msw": "^2.1.2",
|
|
64
64
|
"prettier": "^3.1.1",
|
|
65
65
|
"prettier-plugin-organize-imports": "^3.2.4",
|
|
66
|
+
"prisma": "^5.11.0",
|
|
66
67
|
"semantic-release": "^23.0.2",
|
|
67
68
|
"sequelize": "^6.35.2",
|
|
68
69
|
"sqlite3": "^5.1.7",
|
package/src/server/error.ts
CHANGED
|
@@ -16,3 +16,20 @@ export class OperationError extends Error {
|
|
|
16
16
|
this.operation = operation;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
export class ConflictOperationError extends OperationError {
|
|
21
|
+
constructor(existingOperation: Operation, newOperation: Operation) {
|
|
22
|
+
super(
|
|
23
|
+
'CONFLICT',
|
|
24
|
+
newOperation,
|
|
25
|
+
`Conflicting operation on index ${newOperation.index}`,
|
|
26
|
+
{ existingOperation, newOperation }
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class MissingOperationError extends OperationError {
|
|
32
|
+
constructor(index: number, operation: Operation) {
|
|
33
|
+
super('MISSING', operation, `Missing operation on index ${index}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -16,13 +16,18 @@ import {
|
|
|
16
16
|
BaseAction,
|
|
17
17
|
utils as baseUtils,
|
|
18
18
|
Document,
|
|
19
|
+
DocumentHeader,
|
|
19
20
|
DocumentModel,
|
|
20
21
|
Operation,
|
|
21
22
|
OperationScope
|
|
22
23
|
} from 'document-model/document';
|
|
23
24
|
import { createNanoEvents, Unsubscribe } from 'nanoevents';
|
|
24
25
|
import { MemoryStorage } from '../storage/memory';
|
|
25
|
-
import type {
|
|
26
|
+
import type {
|
|
27
|
+
DocumentDriveStorage,
|
|
28
|
+
DocumentStorage,
|
|
29
|
+
IDriveStorage
|
|
30
|
+
} from '../storage/types';
|
|
26
31
|
import {
|
|
27
32
|
generateUUID,
|
|
28
33
|
isBefore,
|
|
@@ -30,7 +35,11 @@ import {
|
|
|
30
35
|
isNoopUpdate
|
|
31
36
|
} from '../utils';
|
|
32
37
|
import { requestPublicDrive } from '../utils/graphql';
|
|
33
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
ConflictOperationError,
|
|
40
|
+
MissingOperationError,
|
|
41
|
+
OperationError
|
|
42
|
+
} from './error';
|
|
34
43
|
import { ListenerManager } from './listener/manager';
|
|
35
44
|
import {
|
|
36
45
|
CancelPullLoop,
|
|
@@ -89,7 +98,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
89
98
|
) {
|
|
90
99
|
if (status === null) {
|
|
91
100
|
this.syncStatus.delete(driveId);
|
|
92
|
-
} else if (this.
|
|
101
|
+
} else if (this.syncStatus.get(driveId) !== status) {
|
|
93
102
|
this.syncStatus.set(driveId, status);
|
|
94
103
|
this.emit('syncStatus', driveId, status, error);
|
|
95
104
|
}
|
|
@@ -647,11 +656,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
647
656
|
}
|
|
648
657
|
|
|
649
658
|
if (op.index > nextIndex) {
|
|
650
|
-
error = new
|
|
651
|
-
'MISSING',
|
|
652
|
-
op,
|
|
653
|
-
`Missing operation on index ${nextIndex}`
|
|
654
|
-
);
|
|
659
|
+
error = new MissingOperationError(nextIndex, op);
|
|
655
660
|
continue;
|
|
656
661
|
} else if (op.index < nextIndex) {
|
|
657
662
|
const existingOperation = scopeOperations
|
|
@@ -661,19 +666,10 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
661
666
|
existingOperation.index === op.index
|
|
662
667
|
);
|
|
663
668
|
if (existingOperation && existingOperation.hash !== op.hash) {
|
|
664
|
-
error = new
|
|
665
|
-
'CONFLICT',
|
|
666
|
-
op,
|
|
667
|
-
`Conflicting operation on index ${op.index}`,
|
|
668
|
-
{ existingOperation, newOperation: op }
|
|
669
|
-
);
|
|
669
|
+
error = new ConflictOperationError(existingOperation, op);
|
|
670
670
|
continue;
|
|
671
671
|
} else if (!existingOperation) {
|
|
672
|
-
error = new
|
|
673
|
-
'MISSING',
|
|
674
|
-
op,
|
|
675
|
-
`Missing operation on index ${nextIndex}`
|
|
676
|
-
);
|
|
672
|
+
error = new MissingOperationError(nextIndex, op);
|
|
677
673
|
continue;
|
|
678
674
|
}
|
|
679
675
|
} else {
|
|
@@ -773,10 +769,42 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
773
769
|
return this.addOperations(drive, id, [operation]);
|
|
774
770
|
}
|
|
775
771
|
|
|
776
|
-
async
|
|
777
|
-
|
|
778
|
-
|
|
772
|
+
private async _addOperations(
|
|
773
|
+
drive: string,
|
|
774
|
+
id: string,
|
|
775
|
+
callback: (document: DocumentStorage) => Promise<{
|
|
776
|
+
operations: Operation[];
|
|
777
|
+
header: DocumentHeader;
|
|
778
|
+
updatedOperations?: Operation[];
|
|
779
|
+
}>
|
|
780
|
+
) {
|
|
781
|
+
if (!this.storage.addDocumentOperationsWithTransaction) {
|
|
782
|
+
const documentStorage = await this.storage.getDocument(drive, id);
|
|
783
|
+
const result = await callback(documentStorage);
|
|
784
|
+
// saves the applied operations to storage
|
|
785
|
+
if (
|
|
786
|
+
result.operations.length > 0 ||
|
|
787
|
+
(result.updatedOperations &&
|
|
788
|
+
result.updatedOperations.length > 0)
|
|
789
|
+
) {
|
|
790
|
+
await this.storage.addDocumentOperations(
|
|
791
|
+
drive,
|
|
792
|
+
id,
|
|
793
|
+
result.operations,
|
|
794
|
+
result.header,
|
|
795
|
+
result.updatedOperations
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
} else {
|
|
799
|
+
await this.storage.addDocumentOperationsWithTransaction(
|
|
800
|
+
drive,
|
|
801
|
+
id,
|
|
802
|
+
callback
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
779
806
|
|
|
807
|
+
async addOperations(drive: string, id: string, operations: Operation[]) {
|
|
780
808
|
let document: Document | undefined;
|
|
781
809
|
const operationsApplied: Operation[] = [];
|
|
782
810
|
const updatedOperations: Operation[] = [];
|
|
@@ -784,36 +812,29 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
784
812
|
let error: Error | undefined;
|
|
785
813
|
|
|
786
814
|
try {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
);
|
|
794
|
-
|
|
795
|
-
document = result.document;
|
|
796
|
-
|
|
797
|
-
operationsApplied.push(...result.operationsApplied);
|
|
798
|
-
updatedOperations.push(...result.operationsUpdated);
|
|
815
|
+
await this._addOperations(drive, id, async documentStorage => {
|
|
816
|
+
const result = await this._processOperations(
|
|
817
|
+
drive,
|
|
818
|
+
documentStorage,
|
|
819
|
+
operations
|
|
820
|
+
);
|
|
799
821
|
|
|
800
|
-
|
|
801
|
-
|
|
822
|
+
if (!result.document) {
|
|
823
|
+
throw result.error ?? new Error('Invalid document');
|
|
824
|
+
}
|
|
802
825
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
826
|
+
document = result.document;
|
|
827
|
+
error = result.error;
|
|
828
|
+
signals.push(...result.signals);
|
|
829
|
+
operationsApplied.push(...result.operationsApplied);
|
|
830
|
+
updatedOperations.push(...result.operationsUpdated);
|
|
806
831
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
document,
|
|
814
|
-
updatedOperations
|
|
815
|
-
);
|
|
816
|
-
}
|
|
832
|
+
return {
|
|
833
|
+
operations: result.operationsApplied,
|
|
834
|
+
header: result.document,
|
|
835
|
+
updatedOperations: result.operationsUpdated
|
|
836
|
+
};
|
|
837
|
+
});
|
|
817
838
|
|
|
818
839
|
// gets all the different scopes and branches combinations from the operations
|
|
819
840
|
const { scopes, branches } = [
|
|
@@ -908,13 +929,38 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
908
929
|
await this.storage.clearStorage?.();
|
|
909
930
|
}
|
|
910
931
|
|
|
932
|
+
private async _addDriveOperations(
|
|
933
|
+
drive: string,
|
|
934
|
+
callback: (document: DocumentDriveStorage) => Promise<{
|
|
935
|
+
operations: Operation<DocumentDriveAction | BaseAction>[];
|
|
936
|
+
header: DocumentHeader;
|
|
937
|
+
updatedOperations?: Operation[];
|
|
938
|
+
}>
|
|
939
|
+
) {
|
|
940
|
+
if (!this.storage.addDriveOperationsWithTransaction) {
|
|
941
|
+
const documentStorage = await this.storage.getDrive(drive);
|
|
942
|
+
const result = await callback(documentStorage);
|
|
943
|
+
// saves the applied operations to storage
|
|
944
|
+
if (result.operations.length > 0) {
|
|
945
|
+
await this.storage.addDriveOperations(
|
|
946
|
+
drive,
|
|
947
|
+
result.operations,
|
|
948
|
+
result.header
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
return result;
|
|
952
|
+
} else {
|
|
953
|
+
return this.storage.addDriveOperationsWithTransaction(
|
|
954
|
+
drive,
|
|
955
|
+
callback
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
911
960
|
async addDriveOperations(
|
|
912
961
|
drive: string,
|
|
913
962
|
operations: Operation<DocumentDriveAction | BaseAction>[]
|
|
914
963
|
) {
|
|
915
|
-
// retrieves document from storage
|
|
916
|
-
const documentStorage = await this.storage.getDrive(drive);
|
|
917
|
-
|
|
918
964
|
let document: DocumentDriveDocument | undefined;
|
|
919
965
|
const operationsApplied: Operation<DocumentDriveAction | BaseAction>[] =
|
|
920
966
|
[];
|
|
@@ -922,29 +968,28 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
922
968
|
let error: Error | undefined;
|
|
923
969
|
|
|
924
970
|
try {
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
971
|
+
await this._addDriveOperations(drive, async documentStorage => {
|
|
972
|
+
const result = await this._processOperations<
|
|
973
|
+
DocumentDriveDocument,
|
|
974
|
+
DocumentDriveAction
|
|
975
|
+
>(drive, documentStorage, operations.slice());
|
|
929
976
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
977
|
+
document = result.document;
|
|
978
|
+
operationsApplied.push(...result.operationsApplied);
|
|
979
|
+
signals.push(...result.signals);
|
|
980
|
+
error = result.error;
|
|
981
|
+
|
|
982
|
+
return {
|
|
983
|
+
operations: result.operationsApplied,
|
|
984
|
+
header: result.document,
|
|
985
|
+
updatedOperations: result.operationsUpdated
|
|
986
|
+
};
|
|
987
|
+
});
|
|
934
988
|
|
|
935
989
|
if (!document || !isDocumentDrive(document)) {
|
|
936
990
|
throw error ?? new Error('Invalid Document Drive document');
|
|
937
991
|
}
|
|
938
992
|
|
|
939
|
-
// saves the applied operations to storage
|
|
940
|
-
if (operationsApplied.length > 0) {
|
|
941
|
-
await this.storage.addDriveOperations(
|
|
942
|
-
drive,
|
|
943
|
-
operationsApplied,
|
|
944
|
-
document
|
|
945
|
-
);
|
|
946
|
-
}
|
|
947
|
-
|
|
948
993
|
for (const operation of operationsApplied) {
|
|
949
994
|
switch (operation.type) {
|
|
950
995
|
case 'ADD_LISTENER': {
|
package/src/storage/browser.ts
CHANGED
|
@@ -79,9 +79,8 @@ export class BrowserStorage implements IDriveStorage {
|
|
|
79
79
|
updatedOperations
|
|
80
80
|
);
|
|
81
81
|
|
|
82
|
-
await
|
|
83
|
-
|
|
84
|
-
).setItem(this.buildKey(drive, id), {
|
|
82
|
+
const db = await this.db;
|
|
83
|
+
await db.setItem(this.buildKey(drive, id), {
|
|
85
84
|
...document,
|
|
86
85
|
...header,
|
|
87
86
|
operations: mergedUpdatedOperations
|
|
@@ -89,7 +88,8 @@ export class BrowserStorage implements IDriveStorage {
|
|
|
89
88
|
}
|
|
90
89
|
|
|
91
90
|
async getDrives() {
|
|
92
|
-
const
|
|
91
|
+
const db = await this.db;
|
|
92
|
+
const keys = (await db.keys()) ?? [];
|
|
93
93
|
return keys
|
|
94
94
|
.filter(key => key.startsWith(BrowserStorage.DRIVES_KEY))
|
|
95
95
|
.map(key =>
|
|
@@ -131,8 +131,9 @@ export class BrowserStorage implements IDriveStorage {
|
|
|
131
131
|
): Promise<void> {
|
|
132
132
|
const drive = await this.getDrive(id);
|
|
133
133
|
const mergedOperations = mergeOperations(drive.operations, operations);
|
|
134
|
+
const db = await this.db;
|
|
134
135
|
|
|
135
|
-
|
|
136
|
+
await db.setItem(this.buildKey(BrowserStorage.DRIVES_KEY, id), {
|
|
136
137
|
...drive,
|
|
137
138
|
...header,
|
|
138
139
|
operations: mergedOperations
|
package/src/storage/prisma.ts
CHANGED
|
@@ -1,16 +1,40 @@
|
|
|
1
1
|
import { PrismaClient, type Prisma } from '@prisma/client';
|
|
2
|
+
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
|
2
3
|
import {
|
|
4
|
+
DocumentDriveAction,
|
|
3
5
|
DocumentDriveLocalState,
|
|
4
6
|
DocumentDriveState
|
|
5
7
|
} from 'document-model-libs/document-drive';
|
|
6
8
|
import type {
|
|
9
|
+
BaseAction,
|
|
7
10
|
DocumentHeader,
|
|
8
11
|
ExtendedState,
|
|
9
12
|
Operation,
|
|
10
13
|
OperationScope
|
|
11
14
|
} from 'document-model/document';
|
|
15
|
+
import { ConflictOperationError } from '../server/error';
|
|
12
16
|
import { DocumentDriveStorage, DocumentStorage, IDriveStorage } from './types';
|
|
13
17
|
|
|
18
|
+
type Transaction = Omit<
|
|
19
|
+
PrismaClient<Prisma.PrismaClientOptions, never>,
|
|
20
|
+
'$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
function storageToOperation(
|
|
24
|
+
op: Prisma.$OperationPayload['scalars']
|
|
25
|
+
): Operation {
|
|
26
|
+
return {
|
|
27
|
+
skip: op.skip,
|
|
28
|
+
hash: op.hash,
|
|
29
|
+
index: op.index,
|
|
30
|
+
timestamp: new Date(op.timestamp).toISOString(),
|
|
31
|
+
input: op.input,
|
|
32
|
+
type: op.type,
|
|
33
|
+
scope: op.scope as OperationScope
|
|
34
|
+
// attachments: fileRegistry
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
14
38
|
export class PrismaStorage implements IDriveStorage {
|
|
15
39
|
private db: PrismaClient;
|
|
16
40
|
|
|
@@ -30,6 +54,21 @@ export class PrismaStorage implements IDriveStorage {
|
|
|
30
54
|
await this.addDocumentOperations('drives', id, operations, header);
|
|
31
55
|
}
|
|
32
56
|
|
|
57
|
+
async addDriveOperationsWithTransaction(
|
|
58
|
+
drive: string,
|
|
59
|
+
callback: (document: DocumentDriveStorage) => Promise<{
|
|
60
|
+
operations: Operation<DocumentDriveAction | BaseAction>[];
|
|
61
|
+
header: DocumentHeader;
|
|
62
|
+
updatedOperations?: Operation[] | undefined;
|
|
63
|
+
}>
|
|
64
|
+
) {
|
|
65
|
+
return this.addDocumentOperationsWithTransaction(
|
|
66
|
+
'drives',
|
|
67
|
+
drive,
|
|
68
|
+
document => callback(document as DocumentDriveStorage)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
async createDocument(
|
|
34
73
|
drive: string,
|
|
35
74
|
id: string,
|
|
@@ -54,25 +93,23 @@ export class PrismaStorage implements IDriveStorage {
|
|
|
54
93
|
}
|
|
55
94
|
});
|
|
56
95
|
}
|
|
57
|
-
|
|
96
|
+
|
|
97
|
+
private async _addDocumentOperations(
|
|
98
|
+
tx: Transaction,
|
|
58
99
|
drive: string,
|
|
59
100
|
id: string,
|
|
60
101
|
operations: Operation[],
|
|
61
102
|
header: DocumentHeader,
|
|
62
103
|
updatedOperations: Operation[] = []
|
|
63
104
|
): Promise<void> {
|
|
64
|
-
const document = await this.getDocument(drive, id);
|
|
105
|
+
const document = await this.getDocument(drive, id, tx);
|
|
65
106
|
if (!document) {
|
|
66
107
|
throw new Error(`Document with id ${id} not found`);
|
|
67
108
|
}
|
|
68
109
|
|
|
69
|
-
const mergedOperations = [...operations, ...updatedOperations].sort(
|
|
70
|
-
(a, b) => a.index - b.index
|
|
71
|
-
);
|
|
72
|
-
|
|
73
110
|
try {
|
|
74
|
-
await
|
|
75
|
-
data:
|
|
111
|
+
await tx.operation.createMany({
|
|
112
|
+
data: operations.map(op => ({
|
|
76
113
|
driveId: drive,
|
|
77
114
|
documentId: id,
|
|
78
115
|
hash: op.hash,
|
|
@@ -86,7 +123,35 @@ export class PrismaStorage implements IDriveStorage {
|
|
|
86
123
|
}))
|
|
87
124
|
});
|
|
88
125
|
|
|
89
|
-
await
|
|
126
|
+
await Promise.all(
|
|
127
|
+
updatedOperations.map(op =>
|
|
128
|
+
tx.operation.updateMany({
|
|
129
|
+
where: {
|
|
130
|
+
AND: {
|
|
131
|
+
driveId: drive,
|
|
132
|
+
documentId: id,
|
|
133
|
+
scope: op.scope,
|
|
134
|
+
branch: 'main',
|
|
135
|
+
index: op.index
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
data: {
|
|
139
|
+
driveId: drive,
|
|
140
|
+
documentId: id,
|
|
141
|
+
hash: op.hash,
|
|
142
|
+
index: op.index,
|
|
143
|
+
input: op.input as Prisma.InputJsonObject,
|
|
144
|
+
timestamp: op.timestamp,
|
|
145
|
+
type: op.type,
|
|
146
|
+
scope: op.scope,
|
|
147
|
+
branch: 'main',
|
|
148
|
+
skip: op.skip
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
)
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
await tx.document.updateMany({
|
|
90
155
|
where: {
|
|
91
156
|
id,
|
|
92
157
|
driveId: drive
|
|
@@ -97,8 +162,103 @@ export class PrismaStorage implements IDriveStorage {
|
|
|
97
162
|
}
|
|
98
163
|
});
|
|
99
164
|
} catch (e) {
|
|
100
|
-
|
|
165
|
+
// P2002: Unique constraint failed
|
|
166
|
+
// Operation with existing index
|
|
167
|
+
if (
|
|
168
|
+
e instanceof PrismaClientKnownRequestError &&
|
|
169
|
+
e.code === 'P2002'
|
|
170
|
+
) {
|
|
171
|
+
const existingOperation = await this.db.operation.findFirst({
|
|
172
|
+
where: {
|
|
173
|
+
AND: operations.map(op => ({
|
|
174
|
+
driveId: drive,
|
|
175
|
+
documentId: id,
|
|
176
|
+
scope: op.scope,
|
|
177
|
+
branch: 'main',
|
|
178
|
+
index: op.index
|
|
179
|
+
}))
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const conflictOp = operations.find(
|
|
184
|
+
op =>
|
|
185
|
+
existingOperation?.index === op.index &&
|
|
186
|
+
existingOperation.scope === op.scope
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
if (!existingOperation || !conflictOp) {
|
|
190
|
+
throw e;
|
|
191
|
+
} else {
|
|
192
|
+
throw new ConflictOperationError(
|
|
193
|
+
storageToOperation(existingOperation),
|
|
194
|
+
conflictOp
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
throw e;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async addDocumentOperationsWithTransaction(
|
|
204
|
+
drive: string,
|
|
205
|
+
id: string,
|
|
206
|
+
callback: (document: DocumentStorage) => Promise<{
|
|
207
|
+
operations: Operation[];
|
|
208
|
+
header: DocumentHeader;
|
|
209
|
+
updatedOperations?: Operation[] | undefined;
|
|
210
|
+
}>
|
|
211
|
+
) {
|
|
212
|
+
let result: {
|
|
213
|
+
operations: Operation[];
|
|
214
|
+
header: DocumentHeader;
|
|
215
|
+
updatedOperations?: Operation[] | undefined;
|
|
216
|
+
} | null = null;
|
|
217
|
+
|
|
218
|
+
await this.db.$transaction(async tx => {
|
|
219
|
+
const document = await this.getDocument(drive, id, tx);
|
|
220
|
+
|
|
221
|
+
if (!document) {
|
|
222
|
+
throw new Error(`Document with id ${id} not found`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
result = await callback(document);
|
|
226
|
+
|
|
227
|
+
const { operations, header, updatedOperations } = result;
|
|
228
|
+
|
|
229
|
+
return this._addDocumentOperations(
|
|
230
|
+
tx,
|
|
231
|
+
drive,
|
|
232
|
+
id,
|
|
233
|
+
operations,
|
|
234
|
+
header,
|
|
235
|
+
updatedOperations
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
240
|
+
if (!result) {
|
|
241
|
+
throw new Error('No operations were provided');
|
|
101
242
|
}
|
|
243
|
+
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async addDocumentOperations(
|
|
248
|
+
drive: string,
|
|
249
|
+
id: string,
|
|
250
|
+
operations: Operation[],
|
|
251
|
+
header: DocumentHeader,
|
|
252
|
+
updatedOperations: Operation[] = []
|
|
253
|
+
): Promise<void> {
|
|
254
|
+
return this._addDocumentOperations(
|
|
255
|
+
this.db,
|
|
256
|
+
drive,
|
|
257
|
+
id,
|
|
258
|
+
operations,
|
|
259
|
+
header,
|
|
260
|
+
updatedOperations
|
|
261
|
+
);
|
|
102
262
|
}
|
|
103
263
|
|
|
104
264
|
async getDocuments(drive: string) {
|
|
@@ -116,8 +276,8 @@ export class PrismaStorage implements IDriveStorage {
|
|
|
116
276
|
return docs.map(doc => doc.id);
|
|
117
277
|
}
|
|
118
278
|
|
|
119
|
-
async getDocument(driveId: string, id: string) {
|
|
120
|
-
const result = await this.db.document.findFirst({
|
|
279
|
+
async getDocument(driveId: string, id: string, tx?: Transaction) {
|
|
280
|
+
const result = await (tx ?? this.db).document.findFirst({
|
|
121
281
|
where: {
|
|
122
282
|
id: id,
|
|
123
283
|
driveId: driveId
|
|
@@ -151,41 +311,14 @@ export class PrismaStorage implements IDriveStorage {
|
|
|
151
311
|
operations: {
|
|
152
312
|
global: dbDoc.operations
|
|
153
313
|
.filter(op => op.scope === 'global' && !op.clipboard)
|
|
154
|
-
.map(
|
|
155
|
-
skip: op.skip,
|
|
156
|
-
hash: op.hash,
|
|
157
|
-
index: op.index,
|
|
158
|
-
timestamp: new Date(op.timestamp).toISOString(),
|
|
159
|
-
input: op.input,
|
|
160
|
-
type: op.type,
|
|
161
|
-
scope: op.scope as OperationScope
|
|
162
|
-
// attachments: fileRegistry
|
|
163
|
-
})),
|
|
314
|
+
.map(storageToOperation),
|
|
164
315
|
local: dbDoc.operations
|
|
165
316
|
.filter(op => op.scope === 'local' && !op.clipboard)
|
|
166
|
-
.map(
|
|
167
|
-
skip: op.skip,
|
|
168
|
-
hash: op.hash,
|
|
169
|
-
index: op.index,
|
|
170
|
-
timestamp: new Date(op.timestamp).toISOString(),
|
|
171
|
-
input: op.input,
|
|
172
|
-
type: op.type,
|
|
173
|
-
scope: op.scope as OperationScope
|
|
174
|
-
// attachments: fileRegistry
|
|
175
|
-
}))
|
|
317
|
+
.map(storageToOperation)
|
|
176
318
|
},
|
|
177
319
|
clipboard: dbDoc.operations
|
|
178
320
|
.filter(op => op.clipboard)
|
|
179
|
-
.map(
|
|
180
|
-
skip: op.skip,
|
|
181
|
-
hash: op.hash,
|
|
182
|
-
index: op.index,
|
|
183
|
-
timestamp: new Date(op.timestamp).toISOString(),
|
|
184
|
-
input: op.input,
|
|
185
|
-
type: op.type,
|
|
186
|
-
scope: op.scope as OperationScope
|
|
187
|
-
// attachments: fileRegistry
|
|
188
|
-
})),
|
|
321
|
+
.map(storageToOperation),
|
|
189
322
|
revision: dbDoc.revision as Record<OperationScope, number>
|
|
190
323
|
};
|
|
191
324
|
|
|
@@ -219,6 +352,7 @@ export class PrismaStorage implements IDriveStorage {
|
|
|
219
352
|
const doc = await this.getDocument('drives', id);
|
|
220
353
|
return doc as DocumentDriveStorage;
|
|
221
354
|
} catch (e) {
|
|
355
|
+
console.error(e);
|
|
222
356
|
throw new Error(`Drive with id ${id} not found`);
|
|
223
357
|
}
|
|
224
358
|
}
|
package/src/storage/sequelize.ts
CHANGED
|
@@ -39,27 +39,32 @@ export class SequelizeStorage implements IDriveStorage {
|
|
|
39
39
|
const Operation = this.db.define('operation', {
|
|
40
40
|
driveId: {
|
|
41
41
|
type: DataTypes.STRING,
|
|
42
|
-
primaryKey: true
|
|
42
|
+
primaryKey: true,
|
|
43
|
+
unique: 'unique_operation'
|
|
43
44
|
},
|
|
44
45
|
documentId: {
|
|
45
46
|
type: DataTypes.STRING,
|
|
46
|
-
primaryKey: true
|
|
47
|
+
primaryKey: true,
|
|
48
|
+
unique: 'unique_operation'
|
|
47
49
|
},
|
|
48
50
|
hash: DataTypes.STRING,
|
|
49
51
|
index: {
|
|
50
52
|
type: DataTypes.INTEGER,
|
|
51
|
-
primaryKey: true
|
|
53
|
+
primaryKey: true,
|
|
54
|
+
unique: 'unique_operation'
|
|
52
55
|
},
|
|
53
56
|
input: DataTypes.JSON,
|
|
54
57
|
timestamp: DataTypes.DATE,
|
|
55
58
|
type: DataTypes.STRING,
|
|
56
59
|
scope: {
|
|
57
60
|
type: DataTypes.STRING,
|
|
58
|
-
primaryKey: true
|
|
61
|
+
primaryKey: true,
|
|
62
|
+
unique: 'unique_operation'
|
|
59
63
|
},
|
|
60
64
|
branch: {
|
|
61
65
|
type: DataTypes.STRING,
|
|
62
|
-
primaryKey: true
|
|
66
|
+
primaryKey: true,
|
|
67
|
+
unique: 'unique_operation'
|
|
63
68
|
}
|
|
64
69
|
});
|
|
65
70
|
|
|
@@ -141,7 +146,8 @@ export class SequelizeStorage implements IDriveStorage {
|
|
|
141
146
|
drive: string,
|
|
142
147
|
id: string,
|
|
143
148
|
operations: Operation[],
|
|
144
|
-
header: DocumentHeader
|
|
149
|
+
header: DocumentHeader,
|
|
150
|
+
updatedOperations: Operation[] = []
|
|
145
151
|
): Promise<void> {
|
|
146
152
|
const document = await this.getDocument(drive, id);
|
|
147
153
|
if (!document) {
|
|
@@ -153,31 +159,48 @@ export class SequelizeStorage implements IDriveStorage {
|
|
|
153
159
|
throw new Error('Operation model not found');
|
|
154
160
|
}
|
|
155
161
|
|
|
156
|
-
await
|
|
157
|
-
operations.map(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}).then(async () => {
|
|
169
|
-
if (op.attachments) {
|
|
170
|
-
await this._addDocumentOperationAttachments(
|
|
171
|
-
drive,
|
|
172
|
-
id,
|
|
173
|
-
op,
|
|
174
|
-
op.attachments
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
})
|
|
162
|
+
await Operation.bulkCreate(
|
|
163
|
+
operations.map(op => ({
|
|
164
|
+
driveId: drive,
|
|
165
|
+
documentId: id,
|
|
166
|
+
hash: op.hash,
|
|
167
|
+
index: op.index,
|
|
168
|
+
input: op.input,
|
|
169
|
+
timestamp: op.timestamp,
|
|
170
|
+
type: op.type,
|
|
171
|
+
scope: op.scope,
|
|
172
|
+
branch: 'main'
|
|
173
|
+
}))
|
|
179
174
|
);
|
|
180
175
|
|
|
176
|
+
const attachments = operations.reduce<AttachmentInput[]>((acc, op) => {
|
|
177
|
+
if (op.attachments?.length) {
|
|
178
|
+
return acc.concat(
|
|
179
|
+
op.attachments.map(attachment => ({
|
|
180
|
+
driveId: drive,
|
|
181
|
+
documentId: id,
|
|
182
|
+
scope: op.scope,
|
|
183
|
+
branch: 'main',
|
|
184
|
+
index: op.index,
|
|
185
|
+
mimeType: attachment.mimeType,
|
|
186
|
+
fileName: attachment.fileName,
|
|
187
|
+
extension: attachment.extension,
|
|
188
|
+
data: attachment.data,
|
|
189
|
+
hash: attachment.hash
|
|
190
|
+
}))
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return acc;
|
|
194
|
+
}, []);
|
|
195
|
+
if (attachments.length) {
|
|
196
|
+
const Attachment = this.db.models.attachment;
|
|
197
|
+
if (!Attachment) {
|
|
198
|
+
throw new Error('Attachment model not found');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await Attachment.bulkCreate(attachments);
|
|
202
|
+
}
|
|
203
|
+
|
|
181
204
|
const Document = this.db.models.document;
|
|
182
205
|
if (!Document) {
|
|
183
206
|
throw new Error('Document model not found');
|
|
@@ -208,21 +231,19 @@ export class SequelizeStorage implements IDriveStorage {
|
|
|
208
231
|
throw new Error('Attachment model not found');
|
|
209
232
|
}
|
|
210
233
|
|
|
211
|
-
|
|
212
|
-
attachments.map(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
});
|
|
225
|
-
})
|
|
234
|
+
return Attachment.bulkCreate(
|
|
235
|
+
attachments.map(attachment => ({
|
|
236
|
+
driveId: driveId,
|
|
237
|
+
documentId: documentId,
|
|
238
|
+
scope: operation.scope,
|
|
239
|
+
branch: 'main',
|
|
240
|
+
index: operation.index,
|
|
241
|
+
mimeType: attachment.mimeType,
|
|
242
|
+
fileName: attachment.fileName,
|
|
243
|
+
extension: attachment.extension,
|
|
244
|
+
data: attachment.data,
|
|
245
|
+
hash: attachment.hash
|
|
246
|
+
}))
|
|
226
247
|
);
|
|
227
248
|
}
|
|
228
249
|
|
package/src/storage/types.ts
CHANGED
|
@@ -30,6 +30,15 @@ export interface IStorage {
|
|
|
30
30
|
header: DocumentHeader,
|
|
31
31
|
updatedOperations?: Operation[]
|
|
32
32
|
): Promise<void>;
|
|
33
|
+
addDocumentOperationsWithTransaction?(
|
|
34
|
+
drive: string,
|
|
35
|
+
id: string,
|
|
36
|
+
callback: (document: DocumentStorage) => Promise<{
|
|
37
|
+
operations: Operation[];
|
|
38
|
+
header: DocumentHeader;
|
|
39
|
+
updatedOperations?: Operation[];
|
|
40
|
+
}>
|
|
41
|
+
): Promise<void>;
|
|
33
42
|
deleteDocument(drive: string, id: string): Promise<void>;
|
|
34
43
|
}
|
|
35
44
|
|
|
@@ -44,4 +53,12 @@ export interface IDriveStorage extends IStorage {
|
|
|
44
53
|
operations: Operation<DocumentDriveAction | BaseAction>[],
|
|
45
54
|
header: DocumentHeader
|
|
46
55
|
): Promise<void>;
|
|
56
|
+
addDriveOperationsWithTransaction?(
|
|
57
|
+
drive: string,
|
|
58
|
+
callback: (document: DocumentDriveStorage) => Promise<{
|
|
59
|
+
operations: Operation[];
|
|
60
|
+
header: DocumentHeader;
|
|
61
|
+
updatedOperations?: Operation[];
|
|
62
|
+
}>
|
|
63
|
+
): Promise<void>;
|
|
47
64
|
}
|
package/src/utils/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
DocumentOperations,
|
|
11
11
|
Operation
|
|
12
12
|
} from 'document-model/document';
|
|
13
|
+
import { ConflictOperationError } from '../server/error';
|
|
13
14
|
|
|
14
15
|
export function isDocumentDrive(
|
|
15
16
|
document: Document
|
|
@@ -24,9 +25,26 @@ export function mergeOperations<A extends Action = Action>(
|
|
|
24
25
|
currentOperations: DocumentOperations<A>,
|
|
25
26
|
newOperations: Operation<A | BaseAction>[]
|
|
26
27
|
): DocumentOperations<A> {
|
|
28
|
+
let existingOperation: Operation<A | BaseAction> | null = null;
|
|
29
|
+
const conflictOp = newOperations.find(op => {
|
|
30
|
+
const result = currentOperations[op.scope].find(
|
|
31
|
+
o => o.index === op.index && o.scope === op.scope
|
|
32
|
+
);
|
|
33
|
+
if (result) {
|
|
34
|
+
existingOperation = result;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
if (conflictOp) {
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
40
|
+
throw new ConflictOperationError(existingOperation!, conflictOp);
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
return newOperations.reduce((acc, curr) => {
|
|
28
44
|
const operations = acc[curr.scope] ?? [];
|
|
29
|
-
acc[curr.scope] = [...operations, curr]
|
|
45
|
+
acc[curr.scope] = [...operations, curr].sort(
|
|
46
|
+
(a, b) => a.index - b.index
|
|
47
|
+
) as Operation<A>[];
|
|
30
48
|
return acc;
|
|
31
49
|
}, currentOperations);
|
|
32
50
|
}
|