document-drive 0.0.27 → 0.0.29

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.
@@ -1,34 +1,283 @@
1
- import { DocumentDriveAction, utils } from 'document-model-libs/document-drive';
2
1
  import {
2
+ DocumentDriveAction,
3
+ DocumentDriveDocument,
4
+ DocumentDriveState,
5
+ FileNode,
6
+ Trigger,
7
+ isFileNode,
8
+ utils
9
+ } from 'document-model-libs/document-drive';
10
+ import {
11
+ Action,
3
12
  BaseAction,
13
+ Document,
4
14
  DocumentModel,
5
15
  Operation,
16
+ OperationScope,
6
17
  utils as baseUtils
7
18
  } from 'document-model/document';
8
- import { DocumentStorage, IDriveStorage } from '../storage';
9
19
  import { MemoryStorage } from '../storage/memory';
10
- import { isDocumentDrive } from '../utils';
20
+ import type { DocumentStorage, IDriveStorage } from '../storage/types';
21
+ import { generateUUID, isDocumentDrive, isNoopUpdate } from '../utils';
22
+ import { requestPublicDrive } from '../utils/graphql';
23
+ import { OperationError } from './error';
24
+ import { ListenerManager } from './listener/manager';
25
+ import { PullResponderTransmitter } from './listener/transmitter';
26
+ import type { ITransmitter } from './listener/transmitter/types';
11
27
  import {
12
- CreateDocumentInput,
13
- DriveInput,
14
- IDocumentDriveServer,
15
- SignalResult
28
+ BaseDocumentDriveServer,
29
+ IOperationResult,
30
+ RemoteDriveOptions,
31
+ StrandUpdate,
32
+ SyncStatus,
33
+ type CreateDocumentInput,
34
+ type DriveInput,
35
+ type OperationUpdate,
36
+ type SignalResult,
37
+ type SynchronizationUnit
16
38
  } from './types';
17
39
 
40
+ export * from './listener';
18
41
  export type * from './types';
19
42
 
20
- export class DocumentDriveServer implements IDocumentDriveServer {
43
+ export const PULL_DRIVE_INTERVAL = 5000;
44
+
45
+ export class DocumentDriveServer extends BaseDocumentDriveServer {
21
46
  private documentModels: DocumentModel[];
22
47
  private storage: IDriveStorage;
48
+ private listenerStateManager: ListenerManager;
49
+ private triggerMap = new Map<
50
+ DocumentDriveState['id'],
51
+ Map<Trigger['id'], number>
52
+ >();
53
+ private syncStatus = new Map<DocumentDriveState['id'], SyncStatus>();
23
54
 
24
55
  constructor(
25
56
  documentModels: DocumentModel[],
26
57
  storage: IDriveStorage = new MemoryStorage()
27
58
  ) {
59
+ super();
60
+ this.listenerStateManager = new ListenerManager(this);
28
61
  this.documentModels = documentModels;
29
62
  this.storage = storage;
30
63
  }
31
64
 
65
+ private async saveStrand(strand: StrandUpdate) {
66
+ const operations: Operation[] = strand.operations.map(
67
+ ({ index, type, hash, input, skip, timestamp }) => ({
68
+ index,
69
+ type,
70
+ hash,
71
+ input,
72
+ skip,
73
+ timestamp,
74
+ scope: strand.scope,
75
+ branch: strand.branch
76
+ })
77
+ );
78
+
79
+ const result = await (!strand.documentId
80
+ ? this.addDriveOperations(
81
+ strand.driveId,
82
+ operations as Operation<DocumentDriveAction | BaseAction>[]
83
+ )
84
+ : this.addOperations(
85
+ strand.driveId,
86
+ strand.documentId,
87
+ operations
88
+ ));
89
+ this.syncStatus.set(strand.driveId, result.status);
90
+ return result;
91
+ }
92
+
93
+ private shouldSyncRemoteDrive(drive: DocumentDriveDocument) {
94
+ return (
95
+ drive.state.local.availableOffline &&
96
+ drive.state.local.triggers.length > 0
97
+ );
98
+ }
99
+
100
+ private async startSyncRemoteDrive(driveId: string) {
101
+ const drive = await this.getDrive(driveId);
102
+ let driveTriggers = this.triggerMap.get(driveId);
103
+
104
+ for (const trigger of drive.state.local.triggers) {
105
+ if (driveTriggers?.get(trigger.id)) {
106
+ continue;
107
+ }
108
+
109
+ if (!driveTriggers) {
110
+ driveTriggers = new Map();
111
+ this.syncStatus.set(driveId, 'SYNCING');
112
+ }
113
+
114
+ if (PullResponderTransmitter.isPullResponderTrigger(trigger)) {
115
+ const intervalId = PullResponderTransmitter.setupPull(
116
+ driveId,
117
+ trigger,
118
+ this.saveStrand.bind(this),
119
+ error => {
120
+ this.syncStatus.set(
121
+ driveId,
122
+ error instanceof OperationError
123
+ ? error.status
124
+ : 'ERROR'
125
+ );
126
+ },
127
+ acknowledgeSuccess => {}
128
+ );
129
+ driveTriggers.set(trigger.id, intervalId);
130
+ this.triggerMap.set(driveId, driveTriggers);
131
+ }
132
+ }
133
+ }
134
+
135
+ private async stopSyncRemoteDrive(driveId: string) {
136
+ const triggers = this.triggerMap.get(driveId);
137
+ triggers?.forEach(clearInterval);
138
+ return this.triggerMap.delete(driveId);
139
+ }
140
+
141
+ async initialize() {
142
+ await this.listenerStateManager.init();
143
+ const drives = await this.getDrives();
144
+ for (const id of drives) {
145
+ const drive = await this.getDrive(id);
146
+ if (this.shouldSyncRemoteDrive(drive)) {
147
+ this.startSyncRemoteDrive(id);
148
+ }
149
+ }
150
+ }
151
+
152
+ public async getSynchronizationUnits(
153
+ driveId: string,
154
+ documentId?: string[],
155
+ scope?: string[],
156
+ branch?: string[]
157
+ ) {
158
+ const drive = await this.getDrive(driveId);
159
+
160
+ const nodes = drive.state.global.nodes.filter(
161
+ node =>
162
+ isFileNode(node) &&
163
+ (!documentId?.length || documentId.includes(node.id)) // TODO support * as documentId
164
+ ) as FileNode[];
165
+
166
+ if (documentId && !nodes.length) {
167
+ throw new Error('File node not found');
168
+ }
169
+
170
+ const synchronizationUnits: SynchronizationUnit[] = [];
171
+
172
+ for (const node of nodes) {
173
+ const nodeUnits =
174
+ scope?.length || branch?.length
175
+ ? node.synchronizationUnits.filter(
176
+ unit =>
177
+ (!scope?.length || scope.includes(unit.scope)) &&
178
+ (!branch?.length || branch.includes(unit.branch))
179
+ )
180
+ : node.synchronizationUnits;
181
+ if (!nodeUnits.length) {
182
+ continue;
183
+ }
184
+
185
+ const document = await this.getDocument(driveId, node.id);
186
+
187
+ for (const { syncId, scope, branch } of nodeUnits) {
188
+ const operations =
189
+ document.operations[scope as OperationScope] ?? [];
190
+ const lastOperation = operations.pop();
191
+ synchronizationUnits.push({
192
+ syncId,
193
+ scope,
194
+ branch,
195
+ driveId,
196
+ documentId: node.id,
197
+ documentType: node.documentType,
198
+ lastUpdated:
199
+ lastOperation?.timestamp ?? document.lastModified,
200
+ revision: lastOperation?.index ?? 0
201
+ });
202
+ }
203
+ }
204
+ return synchronizationUnits;
205
+ }
206
+
207
+ public async getSynchronizationUnit(
208
+ driveId: string,
209
+ syncId: string
210
+ ): Promise<SynchronizationUnit> {
211
+ const drive = await this.getDrive(driveId);
212
+ const node = drive.state.global.nodes.find(
213
+ node =>
214
+ isFileNode(node) &&
215
+ node.synchronizationUnits.find(unit => unit.syncId === syncId)
216
+ );
217
+
218
+ if (!node || !isFileNode(node)) {
219
+ throw new Error('Synchronization unit not found');
220
+ }
221
+
222
+ const { scope, branch } = node.synchronizationUnits.find(
223
+ unit => unit.syncId === syncId
224
+ )!;
225
+
226
+ const documentId = node.id;
227
+ const document = await this.getDocument(driveId, documentId);
228
+ const operations = document.operations[scope as OperationScope] ?? [];
229
+ const lastOperation = operations.pop();
230
+
231
+ return {
232
+ syncId,
233
+ scope,
234
+ branch,
235
+ driveId,
236
+ documentId,
237
+ documentType: node.documentType,
238
+ lastUpdated: lastOperation?.timestamp ?? document.lastModified,
239
+ revision: lastOperation?.index ?? 0
240
+ };
241
+ }
242
+
243
+ async getOperationData(
244
+ driveId: string,
245
+ syncId: string,
246
+ filter: {
247
+ since?: string | undefined;
248
+ fromRevision?: number | undefined;
249
+ }
250
+ ): Promise<OperationUpdate[]> {
251
+ const { documentId, scope } =
252
+ syncId === '0'
253
+ ? { documentId: '', scope: 'global' }
254
+ : await this.getSynchronizationUnit(driveId, syncId);
255
+
256
+ const document =
257
+ syncId === '0'
258
+ ? await this.getDrive(driveId)
259
+ : await this.getDocument(driveId, documentId); // TODO replace with getDocumentOperations
260
+
261
+ const operations = document.operations[scope as OperationScope] ?? []; // TODO filter by branch also
262
+ const filteredOperations = operations.filter(
263
+ operation =>
264
+ Object.keys(filter).length === 0 ||
265
+ (filter.since !== undefined &&
266
+ filter.since <= operation.timestamp) ||
267
+ (filter.fromRevision !== undefined &&
268
+ operation.index >= filter.fromRevision)
269
+ );
270
+
271
+ return filteredOperations.map(operation => ({
272
+ hash: operation.hash,
273
+ index: operation.index,
274
+ timestamp: operation.timestamp,
275
+ type: operation.type,
276
+ input: operation.input as object,
277
+ skip: operation.skip
278
+ }));
279
+ }
280
+
32
281
  private _getDocumentModel(documentType: string) {
33
282
  const documentModel = this.documentModels.find(
34
283
  model => model.documentModel.id === documentType
@@ -40,7 +289,7 @@ export class DocumentDriveServer implements IDocumentDriveServer {
40
289
  }
41
290
 
42
291
  async addDrive(drive: DriveInput) {
43
- const id = drive.global.id;
292
+ const id = drive.global.id ?? generateUUID();
44
293
  if (!id) {
45
294
  throw new Error('Invalid Drive Id');
46
295
  }
@@ -55,10 +304,83 @@ export class DocumentDriveServer implements IDocumentDriveServer {
55
304
  const document = utils.createDocument({
56
305
  state: drive
57
306
  });
58
- return this.storage.createDrive(id, document);
307
+
308
+ await this.storage.createDrive(id, document);
309
+
310
+ // add listeners to state manager
311
+ for (const listener of drive.local.listeners) {
312
+ await this.listenerStateManager.addListener({
313
+ block: listener.block,
314
+ driveId: id,
315
+ filter: {
316
+ branch: listener.filter.branch ?? [],
317
+ documentId: listener.filter.documentId ?? [],
318
+ documentType: listener.filter.documentType ?? [],
319
+ scope: listener.filter.scope ?? []
320
+ },
321
+ listenerId: listener.listenerId,
322
+ system: listener.system,
323
+ callInfo: listener.callInfo ?? undefined,
324
+ label: listener.label ?? ''
325
+ });
326
+ }
327
+
328
+ // if it is a remote drive that should be available offline, starts
329
+ // the sync process to pull changes from remote every 30 seconds
330
+ if (this.shouldSyncRemoteDrive(document)) {
331
+ await this.startSyncRemoteDrive(id);
332
+ }
333
+ }
334
+
335
+ async addRemoteDrive(url: string, options: RemoteDriveOptions) {
336
+ const { id, name, slug, icon } = await requestPublicDrive(url);
337
+ const {
338
+ pullFilter,
339
+ pullInterval,
340
+ availableOffline,
341
+ sharingType,
342
+ listeners,
343
+ triggers
344
+ } = options;
345
+ const listenerId = await PullResponderTransmitter.registerPullResponder(
346
+ id,
347
+ url,
348
+ pullFilter ?? {
349
+ documentId: ['*'],
350
+ documentType: ['*'],
351
+ branch: ['*'],
352
+ scope: ['*']
353
+ }
354
+ );
355
+
356
+ const pullTrigger: Trigger = {
357
+ id: generateUUID(),
358
+ type: 'PullResponder',
359
+ data: {
360
+ url,
361
+ listenerId,
362
+ interval: pullInterval?.toString() ?? ''
363
+ }
364
+ };
365
+
366
+ return await this.addDrive({
367
+ global: {
368
+ id: id,
369
+ name,
370
+ slug,
371
+ icon: icon ?? null
372
+ },
373
+ local: {
374
+ triggers: [...triggers, pullTrigger],
375
+ listeners: listeners,
376
+ availableOffline,
377
+ sharingType
378
+ }
379
+ });
59
380
  }
60
381
 
61
382
  deleteDrive(id: string) {
383
+ this.stopSyncRemoteDrive(id);
62
384
  return this.storage.deleteDrive(id);
63
385
  }
64
386
 
@@ -104,78 +426,254 @@ export class DocumentDriveServer implements IDocumentDriveServer {
104
426
  return this.storage.getDocuments(drive);
105
427
  }
106
428
 
107
- async createDocument(driveId: string, input: CreateDocumentInput) {
429
+ protected async createDocument(
430
+ driveId: string,
431
+ input: CreateDocumentInput
432
+ ) {
108
433
  const documentModel = this._getDocumentModel(input.documentType);
109
-
110
434
  // TODO validate input.document is of documentType
111
435
  const document = input.document ?? documentModel.utils.createDocument();
112
436
 
113
- return this.storage.createDocument(driveId, input.id, document);
437
+ await this.storage.createDocument(driveId, input.id, document);
438
+
439
+ await this.listenerStateManager.addSyncUnits(
440
+ input.synchronizationUnits.map(({ syncId, scope, branch }) => {
441
+ const lastOperation = document.operations[scope].slice().pop();
442
+ return {
443
+ syncId,
444
+ scope,
445
+ branch,
446
+ driveId,
447
+ documentId: input.id,
448
+ documentType: document.documentType,
449
+ lastUpdated:
450
+ lastOperation?.timestamp ?? document.lastModified,
451
+ revision: lastOperation?.index ?? 0
452
+ };
453
+ })
454
+ );
455
+ return document;
114
456
  }
115
457
 
116
458
  async deleteDocument(driveId: string, id: string) {
117
459
  return this.storage.deleteDocument(driveId, id);
118
460
  }
119
461
 
120
- private async _performOperations(
462
+ async _processOperations<T extends Document, A extends Action>(
121
463
  drive: string,
122
- documentStorage: DocumentStorage,
123
- operations: Operation[]
464
+ documentStorage: DocumentStorage<T>,
465
+ operations: Operation<A | BaseAction>[]
124
466
  ) {
467
+ const operationsApplied: Operation<A | BaseAction>[] = [];
468
+ const operationsUpdated: Operation<A | BaseAction>[] = [];
469
+ let document: T | undefined;
470
+ const signals: SignalResult[] = [];
471
+
472
+ // eslint-disable-next-line prefer-const
473
+ let [operationsToApply, error, updatedOperations] =
474
+ this._validateOperations(operations, documentStorage);
475
+
476
+ const unregisteredOps = [
477
+ ...operationsToApply.map(operation => ({ operation, type: 'new' })),
478
+ ...updatedOperations.map(operation => ({
479
+ operation,
480
+ type: 'update'
481
+ }))
482
+ ].sort((a, b) => a.operation.index - b.operation.index);
483
+
484
+ // retrieves the document's document model and
485
+ // applies the operations using its reducer
486
+ for (const unregisteredOp of unregisteredOps) {
487
+ const { operation, type } = unregisteredOp;
488
+
489
+ try {
490
+ const {
491
+ document: newDocument,
492
+ signals,
493
+ operation: appliedOperation
494
+ } = await this._performOperation(
495
+ drive,
496
+ document ?? documentStorage,
497
+ operation
498
+ );
499
+ document = newDocument;
500
+
501
+ if (type === 'new') {
502
+ operationsApplied.push(appliedOperation);
503
+ } else {
504
+ operationsUpdated.push(appliedOperation);
505
+ }
506
+
507
+ signals.push(...signals);
508
+ } catch (e) {
509
+ if (!error) {
510
+ error =
511
+ e instanceof OperationError
512
+ ? e
513
+ : new OperationError(
514
+ 'ERROR',
515
+ operation,
516
+ (e as Error).message,
517
+ (e as Error).cause
518
+ );
519
+ }
520
+ break;
521
+ }
522
+ }
523
+
524
+ if (!document) {
525
+ document = this._buildDocument(documentStorage);
526
+ }
527
+
528
+ return {
529
+ document,
530
+ operationsApplied,
531
+ signals,
532
+ error,
533
+ operationsUpdated
534
+ } as const;
535
+ }
536
+
537
+ private _validateOperations<T extends Document, A extends Action>(
538
+ operations: Operation<A | BaseAction>[],
539
+ documentStorage: DocumentStorage<T>
540
+ ) {
541
+ const operationsToApply: Operation<A | BaseAction>[] = [];
542
+ const updatedOperations: Operation<A | BaseAction>[] = [];
543
+ let error: OperationError | undefined;
544
+
545
+ // sort operations so from smaller index to biggest
546
+ operations = operations.sort((a, b) => a.index - b.index);
547
+
548
+ for (let i = 0; i < operations.length; i++) {
549
+ const op = operations[i]!;
550
+ const pastOperations = operationsToApply
551
+ .filter(appliedOperation => appliedOperation.scope === op.scope)
552
+ .slice(0, i);
553
+ const scopeOperations = documentStorage.operations[op.scope];
554
+
555
+ // get latest operation
556
+ const ops = [...scopeOperations, ...pastOperations];
557
+ const latestOperation = ops.slice().pop();
558
+
559
+ const noopUpdate = isNoopUpdate(op, latestOperation);
560
+
561
+ let nextIndex = scopeOperations.length + pastOperations.length;
562
+ if (noopUpdate) {
563
+ nextIndex = nextIndex - 1;
564
+ }
565
+
566
+ if (op.index > nextIndex) {
567
+ error = new OperationError(
568
+ 'MISSING',
569
+ op,
570
+ `Missing operation on index ${nextIndex}`
571
+ );
572
+ continue;
573
+ } else if (op.index < nextIndex) {
574
+ const existingOperation = scopeOperations.find(
575
+ existingOperation => existingOperation.index === op.index
576
+ );
577
+ if (existingOperation && existingOperation.hash !== op.hash) {
578
+ error = new OperationError(
579
+ 'CONFLICT',
580
+ op,
581
+ `Conflicting operation on index ${op.index}`,
582
+ { existingOperation, newOperation: op }
583
+ );
584
+ continue;
585
+ }
586
+ } else {
587
+ if (noopUpdate) {
588
+ updatedOperations.push(op);
589
+ } else {
590
+ operationsToApply.push(op);
591
+ }
592
+ }
593
+ }
594
+
595
+ return [operationsToApply, error, updatedOperations] as const;
596
+ }
597
+
598
+ private _buildDocument<T extends Document>(
599
+ documentStorage: DocumentStorage<T>
600
+ ): T {
125
601
  const documentModel = this._getDocumentModel(
126
602
  documentStorage.documentType
127
603
  );
128
- const document = baseUtils.replayDocument(
604
+ return baseUtils.replayDocument(
129
605
  documentStorage.initialState,
130
606
  documentStorage.operations,
131
607
  documentModel.reducer,
132
608
  undefined,
133
609
  documentStorage
610
+ ) as T;
611
+ }
612
+
613
+ private async _performOperation<T extends Document, A extends Action>(
614
+ drive: string,
615
+ documentStorage: DocumentStorage<T>,
616
+ operation: Operation<A | BaseAction>
617
+ ) {
618
+ const documentModel = this._getDocumentModel(
619
+ documentStorage.documentType
134
620
  );
621
+ const document = this._buildDocument(documentStorage);
135
622
 
136
623
  const signalResults: SignalResult[] = [];
137
624
  let newDocument = document;
138
- for (const operation of operations) {
139
- const operationSignals: Promise<SignalResult>[] = [];
140
- newDocument = documentModel.reducer(
141
- newDocument,
142
- operation,
143
- signal => {
144
- let handler: Promise<unknown> | undefined = undefined;
145
- switch (signal.type) {
146
- case 'CREATE_CHILD_DOCUMENT':
147
- handler = this.createDocument(drive, signal.input);
148
- break;
149
- case 'DELETE_CHILD_DOCUMENT':
150
- handler = this.deleteDocument(
151
- drive,
152
- signal.input.id
153
- );
154
- break;
155
- case 'COPY_CHILD_DOCUMENT':
156
- handler = this.getDocument(
157
- drive,
158
- signal.input.id
159
- ).then(documentToCopy =>
625
+
626
+ const operationSignals: (() => Promise<SignalResult>)[] = [];
627
+ newDocument = documentModel.reducer(newDocument, operation, signal => {
628
+ let handler: (() => Promise<unknown>) | undefined = undefined;
629
+ switch (signal.type) {
630
+ case 'CREATE_CHILD_DOCUMENT':
631
+ handler = () => this.createDocument(drive, signal.input);
632
+ break;
633
+ case 'DELETE_CHILD_DOCUMENT':
634
+ handler = () => this.deleteDocument(drive, signal.input.id);
635
+ break;
636
+ case 'COPY_CHILD_DOCUMENT':
637
+ handler = () =>
638
+ this.getDocument(drive, signal.input.id).then(
639
+ documentToCopy =>
160
640
  this.createDocument(drive, {
161
641
  id: signal.input.newId,
162
642
  documentType: documentToCopy.documentType,
163
- document: documentToCopy
643
+ document: documentToCopy,
644
+ synchronizationUnits:
645
+ signal.input.synchronizationUnits
164
646
  })
165
- );
166
- break;
167
- }
168
- if (handler) {
169
- operationSignals.push(
170
- handler.then(result => ({ signal, result }))
171
647
  );
172
- }
173
- }
648
+ break;
649
+ }
650
+ if (handler) {
651
+ operationSignals.push(() =>
652
+ handler().then(result => ({ signal, result }))
653
+ );
654
+ }
655
+ }) as T;
656
+
657
+ const appliedOperation =
658
+ newDocument.operations[operation.scope][operation.index];
659
+ if (!appliedOperation || appliedOperation.hash !== operation.hash) {
660
+ throw new OperationError(
661
+ 'CONFLICT',
662
+ operation,
663
+ `Operation with index ${operation.index} had different result`
174
664
  );
175
- const results = await Promise.all(operationSignals);
176
- signalResults.push(...results);
177
665
  }
178
- return { document: newDocument, signals: signalResults };
666
+
667
+ const results = await Promise.all(
668
+ operationSignals.map(handler => handler())
669
+ );
670
+ signalResults.push(...results);
671
+
672
+ return {
673
+ document: newDocument,
674
+ signals: signalResults,
675
+ operation: appliedOperation
676
+ };
179
677
  }
180
678
 
181
679
  addOperation(drive: string, id: string, operation: Operation) {
@@ -185,37 +683,109 @@ export class DocumentDriveServer implements IDocumentDriveServer {
185
683
  async addOperations(drive: string, id: string, operations: Operation[]) {
186
684
  // retrieves document from storage
187
685
  const documentStorage = await this.storage.getDocument(drive, id);
686
+
687
+ let document: Document | undefined;
688
+ const operationsApplied: Operation[] = [];
689
+ const updatedOperations: Operation[] = [];
690
+ const signals: SignalResult[] = [];
691
+ let error: Error | undefined;
692
+
188
693
  try {
189
694
  // retrieves the document's document model and
190
695
  // applies the operations using its reducer
191
- const { document, signals } = await this._performOperations(
696
+ const result = await this._processOperations(
192
697
  drive,
193
698
  documentStorage,
194
699
  operations
195
700
  );
196
701
 
197
- // saves the updated state of the document and returns it
198
- await this.storage.addDocumentOperations(
702
+ document = result.document;
703
+
704
+ operationsApplied.push(...result.operationsApplied);
705
+ updatedOperations.push(...result.operationsUpdated);
706
+
707
+ signals.push(...result.signals);
708
+ error = result.error;
709
+
710
+ if (!document) {
711
+ throw error ?? new Error('Invalid document');
712
+ }
713
+
714
+ // saves the applied operations to storage
715
+ if (operationsApplied.length > 0 || updatedOperations.length > 0) {
716
+ await this.storage.addDocumentOperations(
717
+ drive,
718
+ id,
719
+ operationsApplied,
720
+ document,
721
+ updatedOperations
722
+ );
723
+ }
724
+
725
+ // gets all the different scopes and branches combinations from the operations
726
+ const { scopes, branches } = [
727
+ ...operationsApplied,
728
+ ...updatedOperations
729
+ ].reduce(
730
+ (acc, operation) => {
731
+ if (!acc.scopes.includes(operation.scope)) {
732
+ acc.scopes.push(operation.scope);
733
+ }
734
+ return acc;
735
+ },
736
+ { scopes: [] as string[], branches: ['main'] }
737
+ );
738
+
739
+ const syncUnits = await this.getSynchronizationUnits(
199
740
  drive,
200
- id,
201
- operations,
202
- document
741
+ [id],
742
+ scopes,
743
+ branches
203
744
  );
745
+ // update listener cache
746
+ for (const syncUnit of syncUnits) {
747
+ this.listenerStateManager
748
+ .updateSynchronizationRevision(
749
+ drive,
750
+ syncUnit.syncId,
751
+ syncUnit.revision,
752
+ syncUnit.lastUpdated
753
+ )
754
+ .catch(error => {
755
+ console.error('Error updating sync revision', error);
756
+ });
757
+ }
758
+
759
+ // after applying all the valid operations,throws
760
+ // an error if there was an invalid operation
761
+ if (error) {
762
+ throw error;
763
+ }
204
764
 
205
765
  return {
206
- success: true,
766
+ status: 'SUCCESS',
207
767
  document,
208
- operations,
768
+ operations: operationsApplied,
209
769
  signals
210
- };
770
+ } satisfies IOperationResult;
211
771
  } catch (error) {
772
+ const operationError =
773
+ error instanceof OperationError
774
+ ? error
775
+ : new OperationError(
776
+ 'ERROR',
777
+ undefined,
778
+ (error as Error).message,
779
+ (error as Error).cause
780
+ );
781
+
212
782
  return {
213
- success: false,
214
- error: error as Error,
215
- document: undefined,
216
- operations,
217
- signals: []
218
- };
783
+ status: operationError.status,
784
+ error: operationError,
785
+ document,
786
+ operations: operationsApplied,
787
+ signals
788
+ } satisfies IOperationResult;
219
789
  }
220
790
  }
221
791
 
@@ -232,39 +802,137 @@ export class DocumentDriveServer implements IDocumentDriveServer {
232
802
  ) {
233
803
  // retrieves document from storage
234
804
  const documentStorage = await this.storage.getDrive(drive);
805
+
806
+ let document: DocumentDriveDocument | undefined;
807
+ const operationsApplied: Operation<DocumentDriveAction | BaseAction>[] =
808
+ [];
809
+ const signals: SignalResult[] = [];
810
+ let error: Error | undefined;
811
+
235
812
  try {
236
- // retrieves the document's document model and
237
- // applies the operations using its reducer
238
- const { document, signals } = await this._performOperations(
239
- drive,
240
- documentStorage,
241
- operations
242
- );
813
+ const result = await this._processOperations<
814
+ DocumentDriveDocument,
815
+ DocumentDriveAction
816
+ >(drive, documentStorage, operations.slice());
817
+
818
+ document = result.document;
819
+ operationsApplied.push(...result.operationsApplied);
820
+ signals.push(...result.signals);
821
+ error = result.error;
243
822
 
244
- if (isDocumentDrive(document)) {
823
+ if (!document || !isDocumentDrive(document)) {
824
+ throw error ?? new Error('Invalid Document Drive document');
825
+ }
826
+
827
+ // saves the applied operations to storage
828
+ if (operationsApplied.length > 0) {
245
829
  await this.storage.addDriveOperations(
246
830
  drive,
247
- operations, // TODO check?
831
+ operationsApplied,
248
832
  document
249
833
  );
834
+ }
835
+
836
+ for (const operation of operationsApplied) {
837
+ if (operation.type === 'ADD_LISTENER') {
838
+ const { listener } = operation.input;
839
+ await this.listenerStateManager.addListener({
840
+ ...listener,
841
+ driveId: drive,
842
+ label: listener.label ?? '',
843
+ system: listener.system ?? false,
844
+ filter: {
845
+ branch: listener.filter.branch ?? [],
846
+ documentId: listener.filter.documentId ?? [],
847
+ documentType: listener.filter.documentType ?? [],
848
+ scope: listener.filter.scope ?? []
849
+ },
850
+ callInfo: {
851
+ data: listener.callInfo?.data ?? '',
852
+ name: listener.callInfo?.name ?? 'PullResponder',
853
+ transmitterType:
854
+ listener.callInfo?.transmitterType ??
855
+ 'PullResponder'
856
+ }
857
+ });
858
+ } else if (operation.type === 'REMOVE_LISTENER') {
859
+ const { listenerId } = operation.input;
860
+ await this.listenerStateManager.removeListener(
861
+ drive,
862
+ listenerId
863
+ );
864
+ }
865
+ }
866
+
867
+ // update listener cache
868
+ const lastOperation = operationsApplied
869
+ .filter(op => op.scope === 'global')
870
+ .slice()
871
+ .pop();
872
+ if (lastOperation) {
873
+ this.listenerStateManager
874
+ .updateSynchronizationRevision(
875
+ drive,
876
+ '0',
877
+ lastOperation.index,
878
+ lastOperation.timestamp
879
+ )
880
+ .catch(error => {
881
+ console.error('Error updating sync revision', error);
882
+ });
883
+ }
884
+
885
+ if (this.shouldSyncRemoteDrive(document)) {
886
+ this.startSyncRemoteDrive(document.state.global.id);
250
887
  } else {
251
- throw new Error('Invalid Document Drive document');
888
+ this.stopSyncRemoteDrive(document.state.global.id);
889
+ }
890
+
891
+ // after applying all the valid operations,throws
892
+ // an error if there was an invalid operation
893
+ if (error) {
894
+ throw error;
252
895
  }
253
896
 
254
897
  return {
255
- success: true,
898
+ status: 'SUCCESS',
256
899
  document,
257
- operations,
900
+ operations: operationsApplied,
258
901
  signals
259
- };
902
+ } satisfies IOperationResult;
260
903
  } catch (error) {
904
+ const operationError =
905
+ error instanceof OperationError
906
+ ? error
907
+ : new OperationError(
908
+ 'ERROR',
909
+ undefined,
910
+ (error as Error).message,
911
+ (error as Error).cause
912
+ );
913
+
261
914
  return {
262
- success: false,
263
- error: error as Error,
264
- document: undefined,
265
- operations,
266
- signals: []
267
- };
915
+ status: operationError.status,
916
+ error: operationError,
917
+ document,
918
+ operations: operationsApplied,
919
+ signals
920
+ } satisfies IOperationResult;
921
+ }
922
+ }
923
+
924
+ getTransmitter(
925
+ driveId: string,
926
+ listenerId: string
927
+ ): Promise<ITransmitter | undefined> {
928
+ return this.listenerStateManager.getTransmitter(driveId, listenerId);
929
+ }
930
+
931
+ getSyncStatus(drive: string): SyncStatus {
932
+ const status = this.syncStatus.get(drive);
933
+ if (!status) {
934
+ throw new Error(`Sync status not found for drive ${drive}`);
268
935
  }
936
+ return status;
269
937
  }
270
938
  }