document-drive 0.0.26 → 0.0.28
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 +18 -10
- package/src/server/error.ts +18 -0
- package/src/server/index.ts +678 -82
- package/src/server/listener/index.ts +2 -0
- package/src/server/listener/manager.ts +382 -0
- package/src/server/listener/transmitter/index.ts +3 -0
- package/src/server/listener/transmitter/pull-responder.ts +308 -0
- package/src/server/listener/transmitter/switchboard-push.ts +63 -0
- package/src/server/listener/transmitter/types.ts +18 -0
- package/src/server/types.ts +209 -23
- package/src/storage/filesystem.ts +2 -2
- package/src/storage/index.ts +0 -4
- package/src/storage/memory.ts +5 -2
- package/src/storage/prisma.ts +65 -18
- package/src/utils/graphql.ts +46 -0
- package/src/{utils.ts → utils/index.ts} +8 -0
package/src/server/index.ts
CHANGED
|
@@ -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 {
|
|
20
|
+
import type { DocumentStorage, IDriveStorage } from '../storage/types';
|
|
21
|
+
import { generateUUID, isDocumentDrive } 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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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(trigger.id, 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
|
-
|
|
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,23 +426,136 @@ export class DocumentDriveServer implements IDocumentDriveServer {
|
|
|
104
426
|
return this.storage.getDocuments(drive);
|
|
105
427
|
}
|
|
106
428
|
|
|
107
|
-
async createDocument(
|
|
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
|
-
|
|
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
|
-
|
|
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>[]
|
|
466
|
+
) {
|
|
467
|
+
const operationsApplied: Operation<A | BaseAction>[] = [];
|
|
468
|
+
let document: T | undefined;
|
|
469
|
+
const signals: SignalResult[] = [];
|
|
470
|
+
|
|
471
|
+
// eslint-disable-next-line prefer-const
|
|
472
|
+
let [operationsToApply, error] = this._validateOperations(
|
|
473
|
+
operations,
|
|
474
|
+
documentStorage
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
// retrieves the document's document model and
|
|
478
|
+
// applies the operations using its reducer
|
|
479
|
+
for (const operation of operationsToApply) {
|
|
480
|
+
try {
|
|
481
|
+
const {
|
|
482
|
+
document: newDocument,
|
|
483
|
+
signals,
|
|
484
|
+
operation: appliedOperation
|
|
485
|
+
} = await this._performOperation(
|
|
486
|
+
drive,
|
|
487
|
+
document ?? documentStorage,
|
|
488
|
+
operation
|
|
489
|
+
);
|
|
490
|
+
document = newDocument;
|
|
491
|
+
operationsApplied.push(appliedOperation);
|
|
492
|
+
signals.push(...signals);
|
|
493
|
+
} catch (e) {
|
|
494
|
+
if (!error) {
|
|
495
|
+
error =
|
|
496
|
+
e instanceof OperationError
|
|
497
|
+
? e
|
|
498
|
+
: new OperationError(
|
|
499
|
+
'ERROR',
|
|
500
|
+
operation,
|
|
501
|
+
(e as Error).message,
|
|
502
|
+
(e as Error).cause
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { document, operationsApplied, signals, error } as const;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private _validateOperations<T extends Document, A extends Action>(
|
|
512
|
+
operations: Operation<A | BaseAction>[],
|
|
513
|
+
documentStorage: DocumentStorage<T>
|
|
514
|
+
) {
|
|
515
|
+
const operationsToApply: Operation<A | BaseAction>[] = [];
|
|
516
|
+
let error: OperationError | undefined;
|
|
517
|
+
|
|
518
|
+
// sort operations so from smaller index to biggest
|
|
519
|
+
operations = operations.sort((a, b) => a.index - b.index);
|
|
520
|
+
|
|
521
|
+
for (let i = 0; i < operations.length; i++) {
|
|
522
|
+
const op = operations[i]!;
|
|
523
|
+
const pastOperations = operationsToApply
|
|
524
|
+
.filter(appliedOperation => appliedOperation.scope === op.scope)
|
|
525
|
+
.slice(0, i);
|
|
526
|
+
const scopeOperations = documentStorage.operations[op.scope];
|
|
527
|
+
|
|
528
|
+
const nextIndex = scopeOperations.length + pastOperations.length;
|
|
529
|
+
if (op.index > nextIndex) {
|
|
530
|
+
error = new OperationError(
|
|
531
|
+
'MISSING',
|
|
532
|
+
op,
|
|
533
|
+
`Missing operation on index ${nextIndex}`
|
|
534
|
+
);
|
|
535
|
+
continue;
|
|
536
|
+
} else if (op.index < nextIndex) {
|
|
537
|
+
const existingOperation =
|
|
538
|
+
scopeOperations.concat(pastOperations)[op.index];
|
|
539
|
+
if (existingOperation && existingOperation.hash !== op.hash) {
|
|
540
|
+
error = new OperationError(
|
|
541
|
+
'CONFLICT',
|
|
542
|
+
op,
|
|
543
|
+
`Conflicting operation on index ${op.index}`
|
|
544
|
+
);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
} else {
|
|
548
|
+
operationsToApply.push(op);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return [operationsToApply, error] as const;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private async _performOperation<T extends Document, A extends Action>(
|
|
556
|
+
drive: string,
|
|
557
|
+
documentStorage: DocumentStorage<T>,
|
|
558
|
+
operation: Operation<A | BaseAction>
|
|
124
559
|
) {
|
|
125
560
|
const documentModel = this._getDocumentModel(
|
|
126
561
|
documentStorage.documentType
|
|
@@ -131,51 +566,62 @@ export class DocumentDriveServer implements IDocumentDriveServer {
|
|
|
131
566
|
documentModel.reducer,
|
|
132
567
|
undefined,
|
|
133
568
|
documentStorage
|
|
134
|
-
);
|
|
569
|
+
) as T;
|
|
135
570
|
|
|
136
571
|
const signalResults: SignalResult[] = [];
|
|
137
572
|
let newDocument = document;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
);
|
|
154
|
-
break;
|
|
155
|
-
case 'COPY_CHILD_DOCUMENT':
|
|
156
|
-
handler = this.getDocument(
|
|
157
|
-
drive,
|
|
158
|
-
signal.input.id
|
|
159
|
-
).then(documentToCopy =>
|
|
573
|
+
|
|
574
|
+
const operationSignals: (() => Promise<SignalResult>)[] = [];
|
|
575
|
+
newDocument = documentModel.reducer(newDocument, operation, signal => {
|
|
576
|
+
let handler: (() => Promise<unknown>) | undefined = undefined;
|
|
577
|
+
switch (signal.type) {
|
|
578
|
+
case 'CREATE_CHILD_DOCUMENT':
|
|
579
|
+
handler = () => this.createDocument(drive, signal.input);
|
|
580
|
+
break;
|
|
581
|
+
case 'DELETE_CHILD_DOCUMENT':
|
|
582
|
+
handler = () => this.deleteDocument(drive, signal.input.id);
|
|
583
|
+
break;
|
|
584
|
+
case 'COPY_CHILD_DOCUMENT':
|
|
585
|
+
handler = () =>
|
|
586
|
+
this.getDocument(drive, signal.input.id).then(
|
|
587
|
+
documentToCopy =>
|
|
160
588
|
this.createDocument(drive, {
|
|
161
589
|
id: signal.input.newId,
|
|
162
590
|
documentType: documentToCopy.documentType,
|
|
163
|
-
document: documentToCopy
|
|
591
|
+
document: documentToCopy,
|
|
592
|
+
synchronizationUnits:
|
|
593
|
+
signal.input.synchronizationUnits
|
|
164
594
|
})
|
|
165
|
-
);
|
|
166
|
-
break;
|
|
167
|
-
}
|
|
168
|
-
if (handler) {
|
|
169
|
-
operationSignals.push(
|
|
170
|
-
handler.then(result => ({ signal, result }))
|
|
171
595
|
);
|
|
172
|
-
|
|
173
|
-
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
if (handler) {
|
|
599
|
+
operationSignals.push(() =>
|
|
600
|
+
handler().then(result => ({ signal, result }))
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
}) as T;
|
|
604
|
+
|
|
605
|
+
const appliedOperation =
|
|
606
|
+
newDocument.operations[operation.scope][operation.index];
|
|
607
|
+
if (!appliedOperation || appliedOperation.hash !== operation.hash) {
|
|
608
|
+
throw new OperationError(
|
|
609
|
+
'CONFLICT',
|
|
610
|
+
operation,
|
|
611
|
+
`Operation with index ${operation.index} had different result`
|
|
174
612
|
);
|
|
175
|
-
const results = await Promise.all(operationSignals);
|
|
176
|
-
signalResults.push(...results);
|
|
177
613
|
}
|
|
178
|
-
|
|
614
|
+
|
|
615
|
+
const results = await Promise.all(
|
|
616
|
+
operationSignals.map(handler => handler())
|
|
617
|
+
);
|
|
618
|
+
signalResults.push(...results);
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
document: newDocument,
|
|
622
|
+
signals: signalResults,
|
|
623
|
+
operation: appliedOperation
|
|
624
|
+
};
|
|
179
625
|
}
|
|
180
626
|
|
|
181
627
|
addOperation(drive: string, id: string, operation: Operation) {
|
|
@@ -185,37 +631,95 @@ export class DocumentDriveServer implements IDocumentDriveServer {
|
|
|
185
631
|
async addOperations(drive: string, id: string, operations: Operation[]) {
|
|
186
632
|
// retrieves document from storage
|
|
187
633
|
const documentStorage = await this.storage.getDocument(drive, id);
|
|
634
|
+
|
|
635
|
+
let document: Document | undefined;
|
|
636
|
+
const operationsApplied: Operation[] = [];
|
|
637
|
+
const signals: SignalResult[] = [];
|
|
638
|
+
let error: Error | undefined;
|
|
639
|
+
|
|
188
640
|
try {
|
|
189
641
|
// retrieves the document's document model and
|
|
190
642
|
// applies the operations using its reducer
|
|
191
|
-
const
|
|
643
|
+
const result = await this._processOperations(
|
|
192
644
|
drive,
|
|
193
645
|
documentStorage,
|
|
194
646
|
operations
|
|
195
647
|
);
|
|
196
648
|
|
|
197
|
-
|
|
649
|
+
document = result.document;
|
|
650
|
+
operationsApplied.push(...result.operationsApplied);
|
|
651
|
+
signals.push(...result.signals);
|
|
652
|
+
error = result.error;
|
|
653
|
+
|
|
654
|
+
if (!document) {
|
|
655
|
+
throw error ?? new Error('Invalid document');
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// saves the applied operations to storage
|
|
198
659
|
await this.storage.addDocumentOperations(
|
|
199
660
|
drive,
|
|
200
661
|
id,
|
|
201
|
-
|
|
662
|
+
operationsApplied,
|
|
202
663
|
document
|
|
203
664
|
);
|
|
204
665
|
|
|
666
|
+
// gets all the different scopes and branches combinations from the operations
|
|
667
|
+
const { scopes, branches } = operationsApplied.reduce(
|
|
668
|
+
(acc, operation) => {
|
|
669
|
+
if (!acc.scopes.includes(operation.scope)) {
|
|
670
|
+
acc.scopes.push(operation.scope);
|
|
671
|
+
}
|
|
672
|
+
return acc;
|
|
673
|
+
},
|
|
674
|
+
{ scopes: [] as string[], branches: ['main'] }
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
const syncUnits = await this.getSynchronizationUnits(
|
|
678
|
+
drive,
|
|
679
|
+
[id],
|
|
680
|
+
scopes,
|
|
681
|
+
branches
|
|
682
|
+
);
|
|
683
|
+
// update listener cache
|
|
684
|
+
for (const syncUnit of syncUnits) {
|
|
685
|
+
await this.listenerStateManager.updateSynchronizationRevision(
|
|
686
|
+
drive,
|
|
687
|
+
syncUnit.syncId,
|
|
688
|
+
syncUnit.revision,
|
|
689
|
+
syncUnit.lastUpdated
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// after applying all the valid operations,throws
|
|
694
|
+
// an error if there was an invalid operation
|
|
695
|
+
if (error) {
|
|
696
|
+
throw error;
|
|
697
|
+
}
|
|
698
|
+
|
|
205
699
|
return {
|
|
206
|
-
|
|
700
|
+
status: 'SUCCESS',
|
|
207
701
|
document,
|
|
208
|
-
operations,
|
|
702
|
+
operations: operationsApplied,
|
|
209
703
|
signals
|
|
210
|
-
};
|
|
704
|
+
} satisfies IOperationResult;
|
|
211
705
|
} catch (error) {
|
|
706
|
+
const operationError =
|
|
707
|
+
error instanceof OperationError
|
|
708
|
+
? error
|
|
709
|
+
: new OperationError(
|
|
710
|
+
'ERROR',
|
|
711
|
+
undefined,
|
|
712
|
+
(error as Error).message,
|
|
713
|
+
(error as Error).cause
|
|
714
|
+
);
|
|
715
|
+
|
|
212
716
|
return {
|
|
213
|
-
|
|
214
|
-
error:
|
|
215
|
-
document
|
|
216
|
-
operations,
|
|
217
|
-
signals
|
|
218
|
-
};
|
|
717
|
+
status: operationError.status,
|
|
718
|
+
error: operationError,
|
|
719
|
+
document,
|
|
720
|
+
operations: operationsApplied,
|
|
721
|
+
signals
|
|
722
|
+
} satisfies IOperationResult;
|
|
219
723
|
}
|
|
220
724
|
}
|
|
221
725
|
|
|
@@ -232,39 +736,131 @@ export class DocumentDriveServer implements IDocumentDriveServer {
|
|
|
232
736
|
) {
|
|
233
737
|
// retrieves document from storage
|
|
234
738
|
const documentStorage = await this.storage.getDrive(drive);
|
|
739
|
+
|
|
740
|
+
let document: DocumentDriveDocument | undefined;
|
|
741
|
+
const operationsApplied: Operation<DocumentDriveAction | BaseAction>[] =
|
|
742
|
+
[];
|
|
743
|
+
const signals: SignalResult[] = [];
|
|
744
|
+
let error: Error | undefined;
|
|
745
|
+
|
|
235
746
|
try {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
747
|
+
const result = await this._processOperations<
|
|
748
|
+
DocumentDriveDocument,
|
|
749
|
+
DocumentDriveAction
|
|
750
|
+
>(drive, documentStorage, operations.slice());
|
|
751
|
+
|
|
752
|
+
document = result.document;
|
|
753
|
+
operationsApplied.push(...result.operationsApplied);
|
|
754
|
+
signals.push(...result.signals);
|
|
755
|
+
error = result.error;
|
|
756
|
+
|
|
757
|
+
if (!document || !isDocumentDrive(document)) {
|
|
758
|
+
throw error ?? new Error('Invalid Document Drive document');
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// saves the applied operations to storage
|
|
762
|
+
await this.storage.addDriveOperations(
|
|
239
763
|
drive,
|
|
240
|
-
|
|
241
|
-
|
|
764
|
+
operationsApplied,
|
|
765
|
+
document
|
|
242
766
|
);
|
|
243
767
|
|
|
244
|
-
|
|
245
|
-
|
|
768
|
+
for (const operation of operationsApplied) {
|
|
769
|
+
if (operation.type === 'ADD_LISTENER') {
|
|
770
|
+
const { listener } = operation.input;
|
|
771
|
+
await this.listenerStateManager.addListener({
|
|
772
|
+
...listener,
|
|
773
|
+
driveId: drive,
|
|
774
|
+
label: listener.label ?? '',
|
|
775
|
+
system: listener.system ?? false,
|
|
776
|
+
filter: {
|
|
777
|
+
branch: listener.filter.branch ?? [],
|
|
778
|
+
documentId: listener.filter.documentId ?? [],
|
|
779
|
+
documentType: listener.filter.documentType ?? [],
|
|
780
|
+
scope: listener.filter.scope ?? []
|
|
781
|
+
},
|
|
782
|
+
callInfo: {
|
|
783
|
+
data: listener.callInfo?.data ?? '',
|
|
784
|
+
name: listener.callInfo?.name ?? 'PullResponder',
|
|
785
|
+
transmitterType:
|
|
786
|
+
listener.callInfo?.transmitterType ??
|
|
787
|
+
'PullResponder'
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
} else if (operation.type === 'REMOVE_LISTENER') {
|
|
791
|
+
const { listenerId } = operation.input;
|
|
792
|
+
await this.listenerStateManager.removeListener(
|
|
793
|
+
drive,
|
|
794
|
+
listenerId
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// update listener cache
|
|
800
|
+
const lastOperation = operationsApplied
|
|
801
|
+
.filter(op => op.scope === 'global')
|
|
802
|
+
.slice()
|
|
803
|
+
.pop();
|
|
804
|
+
if (lastOperation) {
|
|
805
|
+
await this.listenerStateManager.updateSynchronizationRevision(
|
|
246
806
|
drive,
|
|
247
|
-
|
|
248
|
-
|
|
807
|
+
'0',
|
|
808
|
+
lastOperation.index,
|
|
809
|
+
lastOperation.timestamp
|
|
249
810
|
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (this.shouldSyncRemoteDrive(document)) {
|
|
814
|
+
this.startSyncRemoteDrive(document.state.global.id);
|
|
250
815
|
} else {
|
|
251
|
-
|
|
816
|
+
this.stopSyncRemoteDrive(document.state.global.id);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// after applying all the valid operations,throws
|
|
820
|
+
// an error if there was an invalid operation
|
|
821
|
+
if (error) {
|
|
822
|
+
throw error;
|
|
252
823
|
}
|
|
253
824
|
|
|
254
825
|
return {
|
|
255
|
-
|
|
826
|
+
status: 'SUCCESS',
|
|
256
827
|
document,
|
|
257
|
-
operations,
|
|
828
|
+
operations: operationsApplied,
|
|
258
829
|
signals
|
|
259
|
-
};
|
|
830
|
+
} satisfies IOperationResult;
|
|
260
831
|
} catch (error) {
|
|
832
|
+
const operationError =
|
|
833
|
+
error instanceof OperationError
|
|
834
|
+
? error
|
|
835
|
+
: new OperationError(
|
|
836
|
+
'ERROR',
|
|
837
|
+
undefined,
|
|
838
|
+
(error as Error).message,
|
|
839
|
+
(error as Error).cause
|
|
840
|
+
);
|
|
841
|
+
|
|
261
842
|
return {
|
|
262
|
-
|
|
263
|
-
error:
|
|
264
|
-
document
|
|
265
|
-
operations,
|
|
266
|
-
signals
|
|
267
|
-
};
|
|
843
|
+
status: operationError.status,
|
|
844
|
+
error: operationError,
|
|
845
|
+
document,
|
|
846
|
+
operations: operationsApplied,
|
|
847
|
+
signals
|
|
848
|
+
} satisfies IOperationResult;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
getTransmitter(
|
|
853
|
+
driveId: string,
|
|
854
|
+
listenerId: string
|
|
855
|
+
): Promise<ITransmitter | undefined> {
|
|
856
|
+
return this.listenerStateManager.getTransmitter(driveId, listenerId);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
getSyncStatus(drive: string): SyncStatus {
|
|
860
|
+
const status = this.syncStatus.get(drive);
|
|
861
|
+
if (!status) {
|
|
862
|
+
throw new Error(`Sync status not found for drive ${drive}`);
|
|
268
863
|
}
|
|
864
|
+
return status;
|
|
269
865
|
}
|
|
270
866
|
}
|