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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "document-drive",
3
- "version": "1.0.0-alpha.23",
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.8.1",
33
- "document-model": "^1.0.34",
34
- "document-model-libs": "^1.1.51",
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.8.1",
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",
@@ -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
+ }
@@ -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 { DocumentStorage, IDriveStorage } from '../storage/types';
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 { OperationError } from './error';
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.getSyncStatus(driveId) !== status) {
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 OperationError(
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 OperationError(
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 OperationError(
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 addOperations(drive: string, id: string, operations: Operation[]) {
777
- // retrieves document from storage
778
- const documentStorage = await this.storage.getDocument(drive, id);
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
- // retrieves the document's document model and
788
- // applies the operations using its reducer
789
- const result = await this._processOperations(
790
- drive,
791
- documentStorage,
792
- operations
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
- signals.push(...result.signals);
801
- error = result.error;
822
+ if (!result.document) {
823
+ throw result.error ?? new Error('Invalid document');
824
+ }
802
825
 
803
- if (!document) {
804
- throw error ?? new Error('Invalid document');
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
- // saves the applied operations to storage
808
- if (operationsApplied.length > 0 || updatedOperations.length > 0) {
809
- await this.storage.addDocumentOperations(
810
- drive,
811
- id,
812
- operationsApplied,
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
- const result = await this._processOperations<
926
- DocumentDriveDocument,
927
- DocumentDriveAction
928
- >(drive, documentStorage, operations.slice());
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
- document = result.document;
931
- operationsApplied.push(...result.operationsApplied);
932
- signals.push(...result.signals);
933
- error = result.error;
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': {
@@ -79,9 +79,8 @@ export class BrowserStorage implements IDriveStorage {
79
79
  updatedOperations
80
80
  );
81
81
 
82
- await (
83
- await this.db
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 keys = (await (await this.db).keys()) ?? [];
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
- (await this.db).setItem(this.buildKey(BrowserStorage.DRIVES_KEY, id), {
136
+ await db.setItem(this.buildKey(BrowserStorage.DRIVES_KEY, id), {
136
137
  ...drive,
137
138
  ...header,
138
139
  operations: mergedOperations
@@ -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
- async addDocumentOperations(
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 this.db.operation.createMany({
75
- data: mergedOperations.map(op => ({
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 this.db.document.updateMany({
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
- console.log(e);
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(op => ({
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(op => ({
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(op => ({
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
  }
@@ -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 Promise.all(
157
- operations.map(async op => {
158
- return Operation.create({
159
- driveId: drive,
160
- documentId: id,
161
- hash: op.hash,
162
- index: op.index,
163
- input: op.input,
164
- timestamp: op.timestamp,
165
- type: op.type,
166
- scope: op.scope,
167
- branch: 'main'
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
- await Promise.all(
212
- attachments.map(async attachment => {
213
- return Attachment.create({
214
- driveId: driveId,
215
- documentId: documentId,
216
- scope: operation.scope,
217
- branch: 'main',
218
- index: operation.index,
219
- mimeType: attachment.mimeType,
220
- fileName: attachment.fileName,
221
- extension: attachment.extension,
222
- data: attachment.data,
223
- hash: attachment.hash
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
 
@@ -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
  }
@@ -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] as Operation<A>[];
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
  }