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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "document-drive",
3
- "version": "1.0.0-alpha.24",
3
+ "version": "1.0.0-alpha.26",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "module": "./src/index.ts",
@@ -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
  }
@@ -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
- for (const listener of drive.state.local.listeners) {
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 OperationError(
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 OperationError(
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 OperationError(
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 addOperations(drive: string, id: string, operations: Operation[]) {
777
- // retrieves document from storage
778
- const documentStorage = await this.storage.getDocument(drive, id);
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
- // 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);
799
+ await this._addOperations(drive, id, async documentStorage => {
800
+ const result = await this._processOperations(
801
+ drive,
802
+ documentStorage,
803
+ operations
804
+ );
799
805
 
800
- signals.push(...result.signals);
801
- error = result.error;
806
+ if (!result.document) {
807
+ throw result.error ?? new Error('Invalid document');
808
+ }
802
809
 
803
- if (!document) {
804
- throw error ?? new Error('Invalid document');
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
- // 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
- }
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
- const result = await this._processOperations<
926
- DocumentDriveDocument,
927
- DocumentDriveAction
928
- >(drive, documentStorage, operations.slice());
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
- document = result.document;
931
- operationsApplied.push(...result.operationsApplied);
932
- signals.push(...result.signals);
933
- error = result.error;
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
- ListenerCallInfo,
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 init() {
376
- const drives = await this.drive.getDrives();
377
- for (const driveId of drives) {
378
- const drive = await this.drive.getDrive(driveId);
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
 
@@ -267,7 +267,7 @@ export abstract class BaseListenerManager {
267
267
  this.listenerState = listenerState;
268
268
  }
269
269
 
270
- abstract init(): Promise<void>;
270
+ abstract initDrive(drive: DocumentDriveDocument): Promise<void>;
271
271
 
272
272
  abstract addListener(listener: Listener): Promise<ITransmitter>;
273
273
  abstract removeListener(
@@ -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
  }
@@ -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 Promise.all(
162
- operations.map(async op => {
163
- return Operation.create({
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
- }).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
- await Promise.all(
217
- attachments.map(async attachment => {
218
- return Attachment.create({
219
- driveId: driveId,
220
- documentId: documentId,
221
- scope: operation.scope,
222
- branch: 'main',
223
- index: operation.index,
224
- mimeType: attachment.mimeType,
225
- fileName: attachment.fileName,
226
- extension: attachment.extension,
227
- data: attachment.data,
228
- hash: attachment.hash
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
 
@@ -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,7 +10,7 @@ import {
10
10
  DocumentOperations,
11
11
  Operation
12
12
  } from 'document-model/document';
13
- import { OperationError } from '../server/error';
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
- const conflictOp = newOperations.find(op =>
29
- currentOperations[op.scope].find(
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) => {