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.
- package/package.json +18 -10
- package/src/server/error.ts +18 -0
- package/src/server/index.ts +753 -85
- 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 +325 -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/browser.ts +9 -3
- package/src/storage/filesystem.ts +11 -5
- package/src/storage/index.ts +0 -4
- package/src/storage/memory.ts +14 -5
- package/src/storage/prisma.ts +79 -24
- package/src/storage/types.ts +2 -1
- package/src/utils/graphql.ts +46 -0
- package/src/utils/index.ts +77 -0
- package/src/utils.ts +0 -32
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, 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
|
-
|
|
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(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
|
-
|
|
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(
|
|
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>[]
|
|
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
|
-
|
|
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
|
-
|
|
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 =>
|
|
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
|
-
|
|
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
|
|
696
|
+
const result = await this._processOperations(
|
|
192
697
|
drive,
|
|
193
698
|
documentStorage,
|
|
194
699
|
operations
|
|
195
700
|
);
|
|
196
701
|
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
error:
|
|
215
|
-
document
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
error:
|
|
264
|
-
document
|
|
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
|
}
|