document-drive 1.0.0-alpha.24 → 1.0.0-alpha.26
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 +1 -1
- package/src/server/error.ts +17 -0
- package/src/server/index.ts +115 -86
- package/src/server/listener/manager.ts +22 -27
- package/src/server/types.ts +1 -1
- package/src/storage/prisma.ts +176 -42
- package/src/storage/sequelize.ts +55 -39
- package/src/storage/types.ts +17 -0
- package/src/utils/index.ts +12 -14
package/package.json
CHANGED
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
|
}
|
|
@@ -200,7 +209,6 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
200
209
|
}
|
|
201
210
|
|
|
202
211
|
async initialize() {
|
|
203
|
-
await this.listenerStateManager.init();
|
|
204
212
|
const drives = await this.getDrives();
|
|
205
213
|
for (const drive of drives) {
|
|
206
214
|
await this._initializeDrive(drive);
|
|
@@ -214,22 +222,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
214
222
|
await this.startSyncRemoteDrive(driveId);
|
|
215
223
|
}
|
|
216
224
|
|
|
217
|
-
|
|
218
|
-
await this.listenerStateManager.addListener({
|
|
219
|
-
driveId,
|
|
220
|
-
block: listener.block,
|
|
221
|
-
filter: {
|
|
222
|
-
branch: listener.filter.branch ?? [],
|
|
223
|
-
documentId: listener.filter.documentId ?? [],
|
|
224
|
-
documentType: listener.filter.documentType ?? [],
|
|
225
|
-
scope: listener.filter.scope ?? []
|
|
226
|
-
},
|
|
227
|
-
listenerId: listener.listenerId,
|
|
228
|
-
system: listener.system,
|
|
229
|
-
callInfo: listener.callInfo ?? undefined,
|
|
230
|
-
label: listener.label ?? ''
|
|
231
|
-
});
|
|
232
|
-
}
|
|
225
|
+
await this.listenerStateManager.initDrive(drive);
|
|
233
226
|
}
|
|
234
227
|
|
|
235
228
|
public async getSynchronizationUnits(
|
|
@@ -647,11 +640,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
647
640
|
}
|
|
648
641
|
|
|
649
642
|
if (op.index > nextIndex) {
|
|
650
|
-
error = new
|
|
651
|
-
'MISSING',
|
|
652
|
-
op,
|
|
653
|
-
`Missing operation on index ${nextIndex}`
|
|
654
|
-
);
|
|
643
|
+
error = new MissingOperationError(nextIndex, op);
|
|
655
644
|
continue;
|
|
656
645
|
} else if (op.index < nextIndex) {
|
|
657
646
|
const existingOperation = scopeOperations
|
|
@@ -661,19 +650,10 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
661
650
|
existingOperation.index === op.index
|
|
662
651
|
);
|
|
663
652
|
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
|
-
);
|
|
653
|
+
error = new ConflictOperationError(existingOperation, op);
|
|
670
654
|
continue;
|
|
671
655
|
} else if (!existingOperation) {
|
|
672
|
-
error = new
|
|
673
|
-
'MISSING',
|
|
674
|
-
op,
|
|
675
|
-
`Missing operation on index ${nextIndex}`
|
|
676
|
-
);
|
|
656
|
+
error = new MissingOperationError(nextIndex, op);
|
|
677
657
|
continue;
|
|
678
658
|
}
|
|
679
659
|
} else {
|
|
@@ -773,10 +753,42 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
773
753
|
return this.addOperations(drive, id, [operation]);
|
|
774
754
|
}
|
|
775
755
|
|
|
776
|
-
async
|
|
777
|
-
|
|
778
|
-
|
|
756
|
+
private async _addOperations(
|
|
757
|
+
drive: string,
|
|
758
|
+
id: string,
|
|
759
|
+
callback: (document: DocumentStorage) => Promise<{
|
|
760
|
+
operations: Operation[];
|
|
761
|
+
header: DocumentHeader;
|
|
762
|
+
updatedOperations?: Operation[];
|
|
763
|
+
}>
|
|
764
|
+
) {
|
|
765
|
+
if (!this.storage.addDocumentOperationsWithTransaction) {
|
|
766
|
+
const documentStorage = await this.storage.getDocument(drive, id);
|
|
767
|
+
const result = await callback(documentStorage);
|
|
768
|
+
// saves the applied operations to storage
|
|
769
|
+
if (
|
|
770
|
+
result.operations.length > 0 ||
|
|
771
|
+
(result.updatedOperations &&
|
|
772
|
+
result.updatedOperations.length > 0)
|
|
773
|
+
) {
|
|
774
|
+
await this.storage.addDocumentOperations(
|
|
775
|
+
drive,
|
|
776
|
+
id,
|
|
777
|
+
result.operations,
|
|
778
|
+
result.header,
|
|
779
|
+
result.updatedOperations
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
} else {
|
|
783
|
+
await this.storage.addDocumentOperationsWithTransaction(
|
|
784
|
+
drive,
|
|
785
|
+
id,
|
|
786
|
+
callback
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
779
790
|
|
|
791
|
+
async addOperations(drive: string, id: string, operations: Operation[]) {
|
|
780
792
|
let document: Document | undefined;
|
|
781
793
|
const operationsApplied: Operation[] = [];
|
|
782
794
|
const updatedOperations: Operation[] = [];
|
|
@@ -784,36 +796,29 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
784
796
|
let error: Error | undefined;
|
|
785
797
|
|
|
786
798
|
try {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
);
|
|
794
|
-
|
|
795
|
-
document = result.document;
|
|
796
|
-
|
|
797
|
-
operationsApplied.push(...result.operationsApplied);
|
|
798
|
-
updatedOperations.push(...result.operationsUpdated);
|
|
799
|
+
await this._addOperations(drive, id, async documentStorage => {
|
|
800
|
+
const result = await this._processOperations(
|
|
801
|
+
drive,
|
|
802
|
+
documentStorage,
|
|
803
|
+
operations
|
|
804
|
+
);
|
|
799
805
|
|
|
800
|
-
|
|
801
|
-
|
|
806
|
+
if (!result.document) {
|
|
807
|
+
throw result.error ?? new Error('Invalid document');
|
|
808
|
+
}
|
|
802
809
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
810
|
+
document = result.document;
|
|
811
|
+
error = result.error;
|
|
812
|
+
signals.push(...result.signals);
|
|
813
|
+
operationsApplied.push(...result.operationsApplied);
|
|
814
|
+
updatedOperations.push(...result.operationsUpdated);
|
|
806
815
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
document,
|
|
814
|
-
updatedOperations
|
|
815
|
-
);
|
|
816
|
-
}
|
|
816
|
+
return {
|
|
817
|
+
operations: result.operationsApplied,
|
|
818
|
+
header: result.document,
|
|
819
|
+
updatedOperations: result.operationsUpdated
|
|
820
|
+
};
|
|
821
|
+
});
|
|
817
822
|
|
|
818
823
|
// gets all the different scopes and branches combinations from the operations
|
|
819
824
|
const { scopes, branches } = [
|
|
@@ -908,13 +913,38 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
908
913
|
await this.storage.clearStorage?.();
|
|
909
914
|
}
|
|
910
915
|
|
|
916
|
+
private async _addDriveOperations(
|
|
917
|
+
drive: string,
|
|
918
|
+
callback: (document: DocumentDriveStorage) => Promise<{
|
|
919
|
+
operations: Operation<DocumentDriveAction | BaseAction>[];
|
|
920
|
+
header: DocumentHeader;
|
|
921
|
+
updatedOperations?: Operation[];
|
|
922
|
+
}>
|
|
923
|
+
) {
|
|
924
|
+
if (!this.storage.addDriveOperationsWithTransaction) {
|
|
925
|
+
const documentStorage = await this.storage.getDrive(drive);
|
|
926
|
+
const result = await callback(documentStorage);
|
|
927
|
+
// saves the applied operations to storage
|
|
928
|
+
if (result.operations.length > 0) {
|
|
929
|
+
await this.storage.addDriveOperations(
|
|
930
|
+
drive,
|
|
931
|
+
result.operations,
|
|
932
|
+
result.header
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
return result;
|
|
936
|
+
} else {
|
|
937
|
+
return this.storage.addDriveOperationsWithTransaction(
|
|
938
|
+
drive,
|
|
939
|
+
callback
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
911
944
|
async addDriveOperations(
|
|
912
945
|
drive: string,
|
|
913
946
|
operations: Operation<DocumentDriveAction | BaseAction>[]
|
|
914
947
|
) {
|
|
915
|
-
// retrieves document from storage
|
|
916
|
-
const documentStorage = await this.storage.getDrive(drive);
|
|
917
|
-
|
|
918
948
|
let document: DocumentDriveDocument | undefined;
|
|
919
949
|
const operationsApplied: Operation<DocumentDriveAction | BaseAction>[] =
|
|
920
950
|
[];
|
|
@@ -922,29 +952,28 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
922
952
|
let error: Error | undefined;
|
|
923
953
|
|
|
924
954
|
try {
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
955
|
+
await this._addDriveOperations(drive, async documentStorage => {
|
|
956
|
+
const result = await this._processOperations<
|
|
957
|
+
DocumentDriveDocument,
|
|
958
|
+
DocumentDriveAction
|
|
959
|
+
>(drive, documentStorage, operations.slice());
|
|
929
960
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
961
|
+
document = result.document;
|
|
962
|
+
operationsApplied.push(...result.operationsApplied);
|
|
963
|
+
signals.push(...result.signals);
|
|
964
|
+
error = result.error;
|
|
965
|
+
|
|
966
|
+
return {
|
|
967
|
+
operations: result.operationsApplied,
|
|
968
|
+
header: result.document,
|
|
969
|
+
updatedOperations: result.operationsUpdated
|
|
970
|
+
};
|
|
971
|
+
});
|
|
934
972
|
|
|
935
973
|
if (!document || !isDocumentDrive(document)) {
|
|
936
974
|
throw error ?? new Error('Invalid Document Drive document');
|
|
937
975
|
}
|
|
938
976
|
|
|
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
977
|
for (const operation of operationsApplied) {
|
|
949
978
|
switch (operation.type) {
|
|
950
979
|
case 'ADD_LISTENER': {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
DocumentDriveDocument,
|
|
3
3
|
ListenerFilter
|
|
4
4
|
} from 'document-model-libs/document-drive';
|
|
5
5
|
import { OperationScope } from 'document-model/document';
|
|
@@ -372,33 +372,28 @@ export class ListenerManager extends BaseListenerManager {
|
|
|
372
372
|
return false;
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
-
async
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const {
|
|
380
|
-
state: {
|
|
381
|
-
local: { listeners }
|
|
382
|
-
}
|
|
383
|
-
} = drive;
|
|
384
|
-
|
|
385
|
-
for (const listener of listeners) {
|
|
386
|
-
this.addListener({
|
|
387
|
-
block: listener.block,
|
|
388
|
-
driveId,
|
|
389
|
-
filter: {
|
|
390
|
-
branch: listener.filter.branch ?? [],
|
|
391
|
-
documentId: listener.filter.documentId ?? [],
|
|
392
|
-
documentType: listener.filter.documentType,
|
|
393
|
-
scope: listener.filter.scope ?? []
|
|
394
|
-
},
|
|
395
|
-
listenerId: listener.listenerId,
|
|
396
|
-
system: listener.system,
|
|
397
|
-
callInfo:
|
|
398
|
-
(listener.callInfo as ListenerCallInfo) ?? undefined,
|
|
399
|
-
label: listener.label ?? ''
|
|
400
|
-
});
|
|
375
|
+
async initDrive(drive: DocumentDriveDocument) {
|
|
376
|
+
const {
|
|
377
|
+
state: {
|
|
378
|
+
local: { listeners }
|
|
401
379
|
}
|
|
380
|
+
} = drive;
|
|
381
|
+
|
|
382
|
+
for (const listener of listeners) {
|
|
383
|
+
await this.addListener({
|
|
384
|
+
block: listener.block,
|
|
385
|
+
driveId: drive.state.global.id,
|
|
386
|
+
filter: {
|
|
387
|
+
branch: listener.filter.branch ?? [],
|
|
388
|
+
documentId: listener.filter.documentId ?? [],
|
|
389
|
+
documentType: listener.filter.documentType,
|
|
390
|
+
scope: listener.filter.scope ?? []
|
|
391
|
+
},
|
|
392
|
+
listenerId: listener.listenerId,
|
|
393
|
+
system: listener.system,
|
|
394
|
+
callInfo: listener.callInfo ?? undefined,
|
|
395
|
+
label: listener.label ?? ''
|
|
396
|
+
});
|
|
402
397
|
}
|
|
403
398
|
}
|
|
404
399
|
|
package/src/server/types.ts
CHANGED
|
@@ -267,7 +267,7 @@ export abstract class BaseListenerManager {
|
|
|
267
267
|
this.listenerState = listenerState;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
abstract
|
|
270
|
+
abstract initDrive(drive: DocumentDriveDocument): Promise<void>;
|
|
271
271
|
|
|
272
272
|
abstract addListener(listener: Listener): Promise<ITransmitter>;
|
|
273
273
|
abstract removeListener(
|
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
|
@@ -146,7 +146,8 @@ export class SequelizeStorage implements IDriveStorage {
|
|
|
146
146
|
drive: string,
|
|
147
147
|
id: string,
|
|
148
148
|
operations: Operation[],
|
|
149
|
-
header: DocumentHeader
|
|
149
|
+
header: DocumentHeader,
|
|
150
|
+
updatedOperations: Operation[] = []
|
|
150
151
|
): Promise<void> {
|
|
151
152
|
const document = await this.getDocument(drive, id);
|
|
152
153
|
if (!document) {
|
|
@@ -158,31 +159,48 @@ export class SequelizeStorage implements IDriveStorage {
|
|
|
158
159
|
throw new Error('Operation model not found');
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
await
|
|
162
|
-
operations.map(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}).then(async () => {
|
|
174
|
-
if (op.attachments) {
|
|
175
|
-
await this._addDocumentOperationAttachments(
|
|
176
|
-
drive,
|
|
177
|
-
id,
|
|
178
|
-
op,
|
|
179
|
-
op.attachments
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
});
|
|
183
|
-
})
|
|
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
|
+
}))
|
|
184
174
|
);
|
|
185
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
|
+
|
|
186
204
|
const Document = this.db.models.document;
|
|
187
205
|
if (!Document) {
|
|
188
206
|
throw new Error('Document model not found');
|
|
@@ -213,21 +231,19 @@ export class SequelizeStorage implements IDriveStorage {
|
|
|
213
231
|
throw new Error('Attachment model not found');
|
|
214
232
|
}
|
|
215
233
|
|
|
216
|
-
|
|
217
|
-
attachments.map(
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
});
|
|
230
|
-
})
|
|
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
|
+
}))
|
|
231
247
|
);
|
|
232
248
|
}
|
|
233
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,7 +10,7 @@ import {
|
|
|
10
10
|
DocumentOperations,
|
|
11
11
|
Operation
|
|
12
12
|
} from 'document-model/document';
|
|
13
|
-
import {
|
|
13
|
+
import { ConflictOperationError } from '../server/error';
|
|
14
14
|
|
|
15
15
|
export function isDocumentDrive(
|
|
16
16
|
document: Document
|
|
@@ -25,21 +25,19 @@ export function mergeOperations<A extends Action = Action>(
|
|
|
25
25
|
currentOperations: DocumentOperations<A>,
|
|
26
26
|
newOperations: Operation<A | BaseAction>[]
|
|
27
27
|
): DocumentOperations<A> {
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
let existingOperation: Operation<A | BaseAction> | null = null;
|
|
29
|
+
const conflictOp = newOperations.find(op => {
|
|
30
|
+
const result = currentOperations[op.scope].find(
|
|
30
31
|
o => o.index === op.index && o.scope === op.scope
|
|
31
|
-
)
|
|
32
|
-
);
|
|
33
|
-
if (conflictOp) {
|
|
34
|
-
const existingOperation = currentOperations[conflictOp.scope].find(
|
|
35
|
-
o => o.index === conflictOp.index && o.scope === conflictOp.scope
|
|
36
|
-
);
|
|
37
|
-
throw new OperationError(
|
|
38
|
-
'CONFLICT',
|
|
39
|
-
conflictOp,
|
|
40
|
-
`Conflicting operation on index ${conflictOp.index}`,
|
|
41
|
-
{ existingOperation, newOperation: conflictOp }
|
|
42
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);
|
|
43
41
|
}
|
|
44
42
|
|
|
45
43
|
return newOperations.reduce((acc, curr) => {
|