document-drive 1.0.0-alpha.87 → 1.0.0-alpha.89
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 +7 -6
- package/src/server/error.ts +18 -0
- package/src/server/index.ts +313 -111
- package/src/server/listener/manager.ts +4 -0
- package/src/server/types.ts +72 -2
- package/src/storage/browser.ts +29 -2
- package/src/storage/filesystem.ts +2 -1
- package/src/storage/memory.ts +4 -3
- package/src/storage/prisma.ts +27 -2
- package/src/utils/default-drives-manager.ts +239 -0
- package/src/utils/migrations.ts +58 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "document-drive",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.89",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./src/index.ts",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"./queue/base": "./src/queue/base.ts",
|
|
20
20
|
"./utils": "./src/utils/index.ts",
|
|
21
21
|
"./utils/graphql": "./src/utils/graphql.ts",
|
|
22
|
+
"./utils/migrations": "./src/utils/migrations.ts",
|
|
22
23
|
"./logger": "./src/utils/logger.ts"
|
|
23
24
|
},
|
|
24
25
|
"files": [
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
"lint:fix": "eslint src --ext .js,.jsx,.ts,.tsx --fix",
|
|
31
32
|
"format": "prettier . --write",
|
|
32
33
|
"release": "semantic-release",
|
|
33
|
-
"test": "vitest run --coverage",
|
|
34
|
+
"test": "vitest run --coverage --exclude \"test/flaky/**\"",
|
|
34
35
|
"test:watch": "vitest watch"
|
|
35
36
|
},
|
|
36
37
|
"peerDependencies": {
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
"document-model-libs": "^1.57.0"
|
|
39
40
|
},
|
|
40
41
|
"optionalDependencies": {
|
|
41
|
-
"@prisma/client": "5.
|
|
42
|
+
"@prisma/client": "^5.18.0",
|
|
42
43
|
"localforage": "^1.10.0",
|
|
43
44
|
"redis": "^4.6.15",
|
|
44
45
|
"sequelize": "^6.37.3",
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
"@types/uuid": "^9.0.8",
|
|
65
66
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
|
66
67
|
"@typescript-eslint/parser": "^6.21.0",
|
|
67
|
-
"@vitest/coverage-v8": "^
|
|
68
|
+
"@vitest/coverage-v8": "^2.0.5",
|
|
68
69
|
"document-model": "^1.7.0",
|
|
69
70
|
"document-model-libs": "^1.70.0",
|
|
70
71
|
"eslint": "^8.57.0",
|
|
@@ -74,12 +75,12 @@
|
|
|
74
75
|
"msw": "^2.3.1",
|
|
75
76
|
"prettier": "^3.3.3",
|
|
76
77
|
"prettier-plugin-organize-imports": "^3.2.4",
|
|
77
|
-
"prisma": "^5.
|
|
78
|
+
"prisma": "^5.18.0",
|
|
78
79
|
"semantic-release": "^23.1.1",
|
|
79
80
|
"sequelize": "^6.37.2",
|
|
80
81
|
"sqlite3": "^5.1.7",
|
|
81
82
|
"typescript": "^5.5.3",
|
|
82
|
-
"vitest": "^
|
|
83
|
+
"vitest": "^2.0.5"
|
|
83
84
|
},
|
|
84
85
|
"packageManager": "pnpm@9.1.4+sha256.30a1801ac4e723779efed13a21f4c39f9eb6c9fbb4ced101bce06b422593d7c9"
|
|
85
86
|
}
|
package/src/server/error.ts
CHANGED
|
@@ -33,3 +33,21 @@ export class MissingOperationError extends OperationError {
|
|
|
33
33
|
super('MISSING', operation, `Missing operation on index ${index}`);
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
export class DriveAlreadyExistsError extends Error {
|
|
38
|
+
driveId: string;
|
|
39
|
+
|
|
40
|
+
constructor(driveId: string) {
|
|
41
|
+
super(`Drive already exists. ID: ${driveId}`);
|
|
42
|
+
this.driveId = driveId;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class DriveNotFoundError extends Error {
|
|
47
|
+
driveId: string;
|
|
48
|
+
|
|
49
|
+
constructor(driveId: string) {
|
|
50
|
+
super(`Drive with id ${driveId} not found`);
|
|
51
|
+
this.driveId = driveId;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ import type {
|
|
|
42
42
|
IDriveStorage
|
|
43
43
|
} from '../storage/types';
|
|
44
44
|
import { generateUUID, isBefore, isDocumentDrive } from '../utils';
|
|
45
|
+
import { DefaultDrivesManager } from '../utils/default-drives-manager';
|
|
45
46
|
import {
|
|
46
47
|
attachBranch,
|
|
47
48
|
garbageCollect,
|
|
@@ -54,7 +55,11 @@ import {
|
|
|
54
55
|
} from '../utils/document-helpers';
|
|
55
56
|
import { requestPublicDrive } from '../utils/graphql';
|
|
56
57
|
import { logger } from '../utils/logger';
|
|
57
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
ConflictOperationError,
|
|
60
|
+
DriveAlreadyExistsError,
|
|
61
|
+
OperationError
|
|
62
|
+
} from './error';
|
|
58
63
|
import { ListenerManager } from './listener/manager';
|
|
59
64
|
import {
|
|
60
65
|
CancelPullLoop,
|
|
@@ -67,6 +72,7 @@ import {
|
|
|
67
72
|
import {
|
|
68
73
|
AddOperationOptions,
|
|
69
74
|
BaseDocumentDriveServer,
|
|
75
|
+
DocumentDriveServerOptions,
|
|
70
76
|
DriveEvents,
|
|
71
77
|
GetDocumentOptions,
|
|
72
78
|
IOperationResult,
|
|
@@ -75,6 +81,7 @@ import {
|
|
|
75
81
|
StrandUpdate,
|
|
76
82
|
SynchronizationUnitQuery,
|
|
77
83
|
SyncStatus,
|
|
84
|
+
SyncUnitStatusObject,
|
|
78
85
|
type CreateDocumentInput,
|
|
79
86
|
type DriveInput,
|
|
80
87
|
type OperationUpdate,
|
|
@@ -87,7 +94,6 @@ export * from './listener';
|
|
|
87
94
|
export type * from './types';
|
|
88
95
|
|
|
89
96
|
export const PULL_DRIVE_INTERVAL = 5000;
|
|
90
|
-
|
|
91
97
|
export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
92
98
|
private emitter = createNanoEvents<DriveEvents>();
|
|
93
99
|
private cache: ICache;
|
|
@@ -98,15 +104,19 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
98
104
|
DocumentDriveState['id'],
|
|
99
105
|
Map<Trigger['id'], CancelPullLoop>
|
|
100
106
|
>();
|
|
101
|
-
private syncStatus = new Map<string,
|
|
107
|
+
private syncStatus = new Map<string, SyncUnitStatusObject>();
|
|
102
108
|
|
|
103
109
|
private queueManager: IQueueManager;
|
|
110
|
+
private initializePromise: Promise<Error[] | null>;
|
|
111
|
+
|
|
112
|
+
private defaultDrivesManager: DefaultDrivesManager;
|
|
104
113
|
|
|
105
114
|
constructor(
|
|
106
115
|
documentModels: DocumentModel[],
|
|
107
116
|
storage: IDriveStorage = new MemoryStorage(),
|
|
108
117
|
cache: ICache = new InMemoryCache(),
|
|
109
|
-
queueManager: IQueueManager = new BaseQueueManager()
|
|
118
|
+
queueManager: IQueueManager = new BaseQueueManager(),
|
|
119
|
+
options?: DocumentDriveServerOptions
|
|
110
120
|
) {
|
|
111
121
|
super();
|
|
112
122
|
this.listenerStateManager = new ListenerManager(this);
|
|
@@ -114,6 +124,11 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
114
124
|
this.storage = storage;
|
|
115
125
|
this.cache = cache;
|
|
116
126
|
this.queueManager = queueManager;
|
|
127
|
+
this.defaultDrivesManager = new DefaultDrivesManager(
|
|
128
|
+
this,
|
|
129
|
+
this.defaultDrivesManagerDelegate,
|
|
130
|
+
options
|
|
131
|
+
);
|
|
117
132
|
|
|
118
133
|
this.storage.setStorageDelegate?.({
|
|
119
134
|
getCachedOperations: async (drive, id) => {
|
|
@@ -126,18 +141,139 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
126
141
|
}
|
|
127
142
|
}
|
|
128
143
|
});
|
|
144
|
+
|
|
145
|
+
this.initializePromise = this._initialize();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getDefaultRemoteDrives() {
|
|
149
|
+
return this.defaultDrivesManager.getDefaultRemoteDrives();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private getOperationSource(source: StrandUpdateSource) {
|
|
153
|
+
return source.type === 'local' ? 'push' : 'pull';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private getCombinedSyncUnitStatus(
|
|
157
|
+
syncUnitStatus: SyncUnitStatusObject
|
|
158
|
+
): SyncStatus {
|
|
159
|
+
if (!syncUnitStatus.pull && !syncUnitStatus.push) return 'INITIAL_SYNC';
|
|
160
|
+
if (syncUnitStatus.pull === 'INITIAL_SYNC') return 'INITIAL_SYNC';
|
|
161
|
+
if (syncUnitStatus.push === 'INITIAL_SYNC')
|
|
162
|
+
return syncUnitStatus.pull || 'INITIAL_SYNC';
|
|
163
|
+
|
|
164
|
+
const order: Array<SyncStatus> = [
|
|
165
|
+
'ERROR',
|
|
166
|
+
'MISSING',
|
|
167
|
+
'CONFLICT',
|
|
168
|
+
'SYNCING',
|
|
169
|
+
'SUCCESS'
|
|
170
|
+
];
|
|
171
|
+
const sortedStatus = Object.values(syncUnitStatus).sort(
|
|
172
|
+
(a, b) => order.indexOf(a) - order.indexOf(b)
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
176
|
+
return sortedStatus[0]!;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private initSyncStatus(
|
|
180
|
+
syncUnitId: string,
|
|
181
|
+
status: Partial<SyncUnitStatusObject>
|
|
182
|
+
) {
|
|
183
|
+
const defaultSyncUnitStatus: SyncUnitStatusObject = Object.entries(
|
|
184
|
+
status
|
|
185
|
+
).reduce((acc, [key, _status]) => {
|
|
186
|
+
return {
|
|
187
|
+
...acc,
|
|
188
|
+
[key]: _status !== 'SYNCING' ? _status : 'INITIAL_SYNC'
|
|
189
|
+
};
|
|
190
|
+
}, {});
|
|
191
|
+
|
|
192
|
+
this.syncStatus.set(syncUnitId, defaultSyncUnitStatus);
|
|
193
|
+
this.emit(
|
|
194
|
+
'syncStatus',
|
|
195
|
+
syncUnitId,
|
|
196
|
+
this.getCombinedSyncUnitStatus(defaultSyncUnitStatus),
|
|
197
|
+
undefined,
|
|
198
|
+
defaultSyncUnitStatus
|
|
199
|
+
);
|
|
129
200
|
}
|
|
130
201
|
|
|
131
|
-
private
|
|
202
|
+
private async initializeDriveSyncStatus(
|
|
132
203
|
driveId: string,
|
|
133
|
-
|
|
204
|
+
drive: DocumentDriveDocument
|
|
205
|
+
) {
|
|
206
|
+
const syncUnits = await this.getSynchronizationUnitsIds(driveId);
|
|
207
|
+
const syncStatus: SyncUnitStatusObject = {
|
|
208
|
+
pull:
|
|
209
|
+
drive.state.local.triggers.length > 0
|
|
210
|
+
? 'INITIAL_SYNC'
|
|
211
|
+
: undefined,
|
|
212
|
+
push: drive.state.local.listeners.length > 0 ? 'SUCCESS' : undefined
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
if (!syncStatus.pull && !syncStatus.push) return;
|
|
216
|
+
|
|
217
|
+
const syncUnitsIds = [driveId, ...syncUnits.map(s => s.syncId)];
|
|
218
|
+
|
|
219
|
+
for (const syncUnitId of syncUnitsIds) {
|
|
220
|
+
this.initSyncStatus(syncUnitId, syncStatus);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private updateSyncUnitStatus(
|
|
225
|
+
syncUnitId: string,
|
|
226
|
+
status: Partial<SyncUnitStatusObject> | null,
|
|
134
227
|
error?: Error
|
|
135
228
|
) {
|
|
136
229
|
if (status === null) {
|
|
137
|
-
this.syncStatus.delete(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
230
|
+
this.syncStatus.delete(syncUnitId);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const syncUnitStatus = this.syncStatus.get(syncUnitId);
|
|
235
|
+
|
|
236
|
+
if (!syncUnitStatus) {
|
|
237
|
+
this.initSyncStatus(syncUnitId, status);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const shouldUpdateStatus = Object.entries(status).some(
|
|
242
|
+
([key, _status]) =>
|
|
243
|
+
syncUnitStatus[key as keyof SyncUnitStatusObject] !== _status
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (shouldUpdateStatus) {
|
|
247
|
+
const newstatus = Object.entries(status).reduce(
|
|
248
|
+
(acc, [key, _status]) => {
|
|
249
|
+
return {
|
|
250
|
+
...acc,
|
|
251
|
+
// do not replace initial_syncing if it has not finished yet
|
|
252
|
+
[key]:
|
|
253
|
+
acc[key as keyof SyncUnitStatusObject] ===
|
|
254
|
+
'INITIAL_SYNC' && _status === 'SYNCING'
|
|
255
|
+
? 'INITIAL_SYNC'
|
|
256
|
+
: _status
|
|
257
|
+
};
|
|
258
|
+
},
|
|
259
|
+
syncUnitStatus
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const previousCombinedStatus =
|
|
263
|
+
this.getCombinedSyncUnitStatus(syncUnitStatus);
|
|
264
|
+
const newCombinedStatus = this.getCombinedSyncUnitStatus(newstatus);
|
|
265
|
+
|
|
266
|
+
this.syncStatus.set(syncUnitId, newstatus);
|
|
267
|
+
|
|
268
|
+
if (previousCombinedStatus !== newCombinedStatus) {
|
|
269
|
+
this.emit(
|
|
270
|
+
'syncStatus',
|
|
271
|
+
syncUnitId,
|
|
272
|
+
this.getCombinedSyncUnitStatus(newstatus),
|
|
273
|
+
error,
|
|
274
|
+
newstatus
|
|
275
|
+
);
|
|
276
|
+
}
|
|
141
277
|
}
|
|
142
278
|
}
|
|
143
279
|
|
|
@@ -162,7 +298,27 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
162
298
|
));
|
|
163
299
|
|
|
164
300
|
if (result.status === 'ERROR') {
|
|
165
|
-
|
|
301
|
+
const syncUnits =
|
|
302
|
+
strand.documentId !== ''
|
|
303
|
+
? (
|
|
304
|
+
await this.getSynchronizationUnitsIds(
|
|
305
|
+
strand.driveId,
|
|
306
|
+
[strand.documentId],
|
|
307
|
+
[strand.scope],
|
|
308
|
+
[strand.branch]
|
|
309
|
+
)
|
|
310
|
+
).map(s => s.syncId)
|
|
311
|
+
: [strand.driveId];
|
|
312
|
+
|
|
313
|
+
const operationSource = this.getOperationSource(source);
|
|
314
|
+
|
|
315
|
+
for (const syncUnit of syncUnits) {
|
|
316
|
+
this.updateSyncUnitStatus(
|
|
317
|
+
syncUnit,
|
|
318
|
+
{ [operationSource]: result.status },
|
|
319
|
+
result.error
|
|
320
|
+
);
|
|
321
|
+
}
|
|
166
322
|
}
|
|
167
323
|
this.emit('strandUpdate', strand);
|
|
168
324
|
return result;
|
|
@@ -177,11 +333,10 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
177
333
|
`Listener ${listener.listener.label ?? listener.listener.listenerId} error:`,
|
|
178
334
|
error
|
|
179
335
|
);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
);
|
|
336
|
+
|
|
337
|
+
const status = error instanceof OperationError ? error.status : 'ERROR';
|
|
338
|
+
|
|
339
|
+
this.updateSyncUnitStatus(driveId, { push: status }, error);
|
|
185
340
|
}
|
|
186
341
|
|
|
187
342
|
private shouldSyncRemoteDrive(drive: DocumentDriveDocument) {
|
|
@@ -213,10 +368,10 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
213
368
|
driveTriggers = new Map();
|
|
214
369
|
}
|
|
215
370
|
|
|
216
|
-
this.
|
|
371
|
+
this.updateSyncUnitStatus(driveId, { pull: 'SYNCING' });
|
|
217
372
|
|
|
218
373
|
for (const syncUnit of syncUnits) {
|
|
219
|
-
this.
|
|
374
|
+
this.updateSyncUnitStatus(syncUnit.syncId, { pull: 'SYNCING' });
|
|
220
375
|
}
|
|
221
376
|
|
|
222
377
|
if (PullResponderTransmitter.isPullResponderTrigger(trigger)) {
|
|
@@ -226,11 +381,14 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
226
381
|
trigger,
|
|
227
382
|
this.saveStrand.bind(this),
|
|
228
383
|
error => {
|
|
229
|
-
|
|
230
|
-
driveId,
|
|
384
|
+
const statusError =
|
|
231
385
|
error instanceof OperationError
|
|
232
386
|
? error.status
|
|
233
|
-
: 'ERROR'
|
|
387
|
+
: 'ERROR';
|
|
388
|
+
|
|
389
|
+
this.updateSyncUnitStatus(
|
|
390
|
+
driveId,
|
|
391
|
+
{ pull: statusError },
|
|
234
392
|
error
|
|
235
393
|
);
|
|
236
394
|
|
|
@@ -248,28 +406,47 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
248
406
|
const errorRevision = revisions.filter(
|
|
249
407
|
r => r.status !== 'SUCCESS'
|
|
250
408
|
);
|
|
409
|
+
|
|
251
410
|
if (errorRevision.length < 1) {
|
|
252
|
-
this.
|
|
411
|
+
this.updateSyncUnitStatus(driveId, {
|
|
412
|
+
pull: 'SUCCESS'
|
|
413
|
+
});
|
|
253
414
|
}
|
|
254
415
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
);
|
|
416
|
+
const documentIdsFromRevision = revisions
|
|
417
|
+
.filter(rev => rev.documentId !== '')
|
|
418
|
+
.map(rev => rev.documentId);
|
|
259
419
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
)
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
420
|
+
this.getSynchronizationUnitsIds(
|
|
421
|
+
driveId,
|
|
422
|
+
documentIdsFromRevision
|
|
423
|
+
)
|
|
424
|
+
.then(revSyncUnits => {
|
|
425
|
+
for (const syncUnit of revSyncUnits) {
|
|
426
|
+
const fileErrorRevision =
|
|
427
|
+
errorRevision.find(
|
|
428
|
+
r =>
|
|
429
|
+
r.documentId ===
|
|
430
|
+
syncUnit.documentId
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
if (fileErrorRevision) {
|
|
434
|
+
this.updateSyncUnitStatus(
|
|
435
|
+
syncUnit.syncId,
|
|
436
|
+
{ pull: fileErrorRevision.status },
|
|
437
|
+
fileErrorRevision.error
|
|
438
|
+
);
|
|
439
|
+
} else {
|
|
440
|
+
this.updateSyncUnitStatus(
|
|
441
|
+
syncUnit.syncId,
|
|
442
|
+
{
|
|
443
|
+
pull: 'SUCCESS'
|
|
444
|
+
}
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
})
|
|
449
|
+
.catch(console.error);
|
|
273
450
|
|
|
274
451
|
// if it is the first pull and returns empty
|
|
275
452
|
// then updates corresponding push transmitter
|
|
@@ -311,20 +488,25 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
311
488
|
|
|
312
489
|
private async stopSyncRemoteDrive(driveId: string) {
|
|
313
490
|
const syncUnits = await this.getSynchronizationUnitsIds(driveId);
|
|
314
|
-
const
|
|
491
|
+
const filesNodeSyncId = syncUnits
|
|
315
492
|
.filter(syncUnit => syncUnit.documentId !== '')
|
|
316
|
-
.map(syncUnit => syncUnit.
|
|
493
|
+
.map(syncUnit => syncUnit.syncId);
|
|
317
494
|
|
|
318
495
|
const triggers = this.triggerMap.get(driveId);
|
|
319
496
|
triggers?.forEach(cancel => cancel());
|
|
320
|
-
this.
|
|
497
|
+
this.updateSyncUnitStatus(driveId, null);
|
|
321
498
|
|
|
322
|
-
for (const
|
|
323
|
-
this.
|
|
499
|
+
for (const fileNodeSyncId of filesNodeSyncId) {
|
|
500
|
+
this.updateSyncUnitStatus(fileNodeSyncId, null);
|
|
324
501
|
}
|
|
325
502
|
return this.triggerMap.delete(driveId);
|
|
326
503
|
}
|
|
327
504
|
|
|
505
|
+
private defaultDrivesManagerDelegate = {
|
|
506
|
+
emit: (...args: Parameters<DriveEvents['defaultRemoteDrive']>) =>
|
|
507
|
+
this.emit('defaultRemoteDrive', ...args)
|
|
508
|
+
};
|
|
509
|
+
|
|
328
510
|
private queueDelegate = {
|
|
329
511
|
checkDocumentExists: (
|
|
330
512
|
driveId: string,
|
|
@@ -372,7 +554,17 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
372
554
|
}
|
|
373
555
|
};
|
|
374
556
|
|
|
375
|
-
|
|
557
|
+
initialize() {
|
|
558
|
+
return this.initializePromise;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private async _initialize() {
|
|
562
|
+
try {
|
|
563
|
+
await this.defaultDrivesManager.removeOldremoteDrives();
|
|
564
|
+
} catch (error) {
|
|
565
|
+
logger.error(error);
|
|
566
|
+
}
|
|
567
|
+
|
|
376
568
|
const errors: Error[] = [];
|
|
377
569
|
const drives = await this.getDrives();
|
|
378
570
|
for (const drive of drives) {
|
|
@@ -387,6 +579,8 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
387
579
|
errors.push(error);
|
|
388
580
|
});
|
|
389
581
|
|
|
582
|
+
await this.defaultDrivesManager.initializeDefaultRemoteDrives();
|
|
583
|
+
|
|
390
584
|
// if network connect comes back online
|
|
391
585
|
// then triggers the listeners update
|
|
392
586
|
if (typeof window !== 'undefined') {
|
|
@@ -411,6 +605,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
411
605
|
|
|
412
606
|
private async _initializeDrive(driveId: string) {
|
|
413
607
|
const drive = await this.getDrive(driveId);
|
|
608
|
+
await this.initializeDriveSyncStatus(driveId, drive);
|
|
414
609
|
|
|
415
610
|
if (this.shouldSyncRemoteDrive(drive)) {
|
|
416
611
|
await this.startSyncRemoteDrive(driveId);
|
|
@@ -689,7 +884,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
689
884
|
|
|
690
885
|
const drives = await this.storage.getDrives();
|
|
691
886
|
if (drives.includes(id)) {
|
|
692
|
-
throw new
|
|
887
|
+
throw new DriveAlreadyExistsError(id);
|
|
693
888
|
}
|
|
694
889
|
|
|
695
890
|
const document = utils.createDocument({
|
|
@@ -711,7 +906,9 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
711
906
|
url: string,
|
|
712
907
|
options: RemoteDriveOptions
|
|
713
908
|
): Promise<DocumentDriveDocument> {
|
|
714
|
-
const { id, name, slug, icon } =
|
|
909
|
+
const { id, name, slug, icon } =
|
|
910
|
+
options.expectedDriveInfo || (await requestPublicDrive(url));
|
|
911
|
+
|
|
715
912
|
const {
|
|
716
913
|
pullFilter,
|
|
717
914
|
pullInterval,
|
|
@@ -879,6 +1076,16 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
879
1076
|
};
|
|
880
1077
|
await this.storage.createDocument(driveId, input.id, documentStorage);
|
|
881
1078
|
|
|
1079
|
+
// set initial state for new syncUnits
|
|
1080
|
+
for (const syncUnit of input.synchronizationUnits) {
|
|
1081
|
+
this.initSyncStatus(syncUnit.syncId, {
|
|
1082
|
+
pull: this.triggerMap.get(driveId) ? 'INITIAL_SYNC' : undefined,
|
|
1083
|
+
push: this.listenerStateManager.driveHasListeners(driveId)
|
|
1084
|
+
? 'SUCCESS'
|
|
1085
|
+
: undefined
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
|
|
882
1089
|
// if the document contains operations then
|
|
883
1090
|
// stores the operations in the storage
|
|
884
1091
|
const operations = Object.values(document.operations).flat();
|
|
@@ -907,6 +1114,11 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
907
1114
|
const syncUnits = await this.getSynchronizationUnitsIds(driveId, [
|
|
908
1115
|
id
|
|
909
1116
|
]);
|
|
1117
|
+
|
|
1118
|
+
// remove document sync units status when a document is deleted
|
|
1119
|
+
for (const syncUnit of syncUnits) {
|
|
1120
|
+
this.updateSyncUnitStatus(syncUnit.syncId, null);
|
|
1121
|
+
}
|
|
910
1122
|
await this.listenerStateManager.removeSyncUnits(driveId, syncUnits);
|
|
911
1123
|
} catch (error) {
|
|
912
1124
|
logger.warn('Error deleting document', error);
|
|
@@ -989,7 +1201,9 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
989
1201
|
);
|
|
990
1202
|
document = appliedResult.document;
|
|
991
1203
|
signals.push(...appliedResult.signals);
|
|
992
|
-
operationsApplied.push(
|
|
1204
|
+
operationsApplied.push(appliedResult.operation);
|
|
1205
|
+
|
|
1206
|
+
// TODO what to do if one of the applied operations has an error?
|
|
993
1207
|
} catch (e) {
|
|
994
1208
|
error =
|
|
995
1209
|
e instanceof OperationError
|
|
@@ -1189,21 +1403,26 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
1189
1403
|
{ skip: operation.skip, reuseOperationResultingState: true }
|
|
1190
1404
|
) as T;
|
|
1191
1405
|
|
|
1192
|
-
const
|
|
1406
|
+
const appliedOperations = newDocument.operations[
|
|
1407
|
+
operation.scope
|
|
1408
|
+
].filter(
|
|
1193
1409
|
op => op.index == operation.index && op.skip == operation.skip
|
|
1194
1410
|
);
|
|
1411
|
+
const appliedOperation = appliedOperations.at(0);
|
|
1195
1412
|
|
|
1196
|
-
if (appliedOperation
|
|
1413
|
+
if (!appliedOperation) {
|
|
1197
1414
|
throw new OperationError(
|
|
1198
1415
|
'ERROR',
|
|
1199
1416
|
operation,
|
|
1200
1417
|
`Operation with index ${operation.index}:${operation.skip} was not applied.`
|
|
1201
1418
|
);
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1419
|
+
}
|
|
1420
|
+
if (
|
|
1421
|
+
!appliedOperation.error &&
|
|
1422
|
+
appliedOperation.hash !== operation.hash &&
|
|
1204
1423
|
!skipHashValidation
|
|
1205
1424
|
) {
|
|
1206
|
-
throw new ConflictOperationError(operation, appliedOperation
|
|
1425
|
+
throw new ConflictOperationError(operation, appliedOperation);
|
|
1207
1426
|
}
|
|
1208
1427
|
|
|
1209
1428
|
for (const signalHandler of operationSignals) {
|
|
@@ -1546,26 +1765,38 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
1546
1765
|
: (options?.source ?? { type: 'local' });
|
|
1547
1766
|
|
|
1548
1767
|
// update listener cache
|
|
1768
|
+
|
|
1769
|
+
const operationSource = this.getOperationSource(source);
|
|
1770
|
+
|
|
1549
1771
|
this.listenerStateManager
|
|
1550
1772
|
.updateSynchronizationRevisions(
|
|
1551
1773
|
drive,
|
|
1552
1774
|
syncUnits,
|
|
1553
1775
|
source,
|
|
1554
1776
|
() => {
|
|
1555
|
-
this.
|
|
1777
|
+
this.updateSyncUnitStatus(drive, {
|
|
1778
|
+
[operationSource]: 'SYNCING'
|
|
1779
|
+
});
|
|
1556
1780
|
|
|
1557
1781
|
for (const syncUnit of syncUnits) {
|
|
1558
|
-
this.
|
|
1782
|
+
this.updateSyncUnitStatus(syncUnit.syncId, {
|
|
1783
|
+
[operationSource]: 'SYNCING'
|
|
1784
|
+
});
|
|
1559
1785
|
}
|
|
1560
1786
|
},
|
|
1561
1787
|
this.handleListenerError.bind(this),
|
|
1562
1788
|
options?.forceSync ?? source.type === 'local'
|
|
1563
1789
|
)
|
|
1564
1790
|
.then(updates => {
|
|
1565
|
-
updates.length &&
|
|
1791
|
+
updates.length &&
|
|
1792
|
+
this.updateSyncUnitStatus(drive, {
|
|
1793
|
+
[operationSource]: 'SUCCESS'
|
|
1794
|
+
});
|
|
1566
1795
|
|
|
1567
1796
|
for (const syncUnit of syncUnits) {
|
|
1568
|
-
this.
|
|
1797
|
+
this.updateSyncUnitStatus(syncUnit.syncId, {
|
|
1798
|
+
[operationSource]: 'SUCCESS'
|
|
1799
|
+
});
|
|
1569
1800
|
}
|
|
1570
1801
|
})
|
|
1571
1802
|
.catch(error => {
|
|
@@ -1573,12 +1804,20 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
1573
1804
|
'Non handled error updating sync revision',
|
|
1574
1805
|
error
|
|
1575
1806
|
);
|
|
1576
|
-
this.
|
|
1807
|
+
this.updateSyncUnitStatus(
|
|
1808
|
+
drive,
|
|
1809
|
+
{
|
|
1810
|
+
[operationSource]: 'ERROR'
|
|
1811
|
+
},
|
|
1812
|
+
error as Error
|
|
1813
|
+
);
|
|
1577
1814
|
|
|
1578
1815
|
for (const syncUnit of syncUnits) {
|
|
1579
|
-
this.
|
|
1816
|
+
this.updateSyncUnitStatus(
|
|
1580
1817
|
syncUnit.syncId,
|
|
1581
|
-
|
|
1818
|
+
{
|
|
1819
|
+
[operationSource]: 'ERROR'
|
|
1820
|
+
},
|
|
1582
1821
|
error as Error
|
|
1583
1822
|
);
|
|
1584
1823
|
}
|
|
@@ -1772,7 +2011,6 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
1772
2011
|
return result;
|
|
1773
2012
|
}
|
|
1774
2013
|
|
|
1775
|
-
const prevSyncUnits = await this.getSynchronizationUnitsIds(drive);
|
|
1776
2014
|
try {
|
|
1777
2015
|
await this._addDriveOperations(drive, async documentStorage => {
|
|
1778
2016
|
const result = await this._processOperations<
|
|
@@ -1811,26 +2049,6 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
1811
2049
|
}
|
|
1812
2050
|
}
|
|
1813
2051
|
|
|
1814
|
-
const syncUnits = await this.getSynchronizationUnitsIds(
|
|
1815
|
-
drive,
|
|
1816
|
-
undefined,
|
|
1817
|
-
undefined,
|
|
1818
|
-
undefined,
|
|
1819
|
-
undefined,
|
|
1820
|
-
document
|
|
1821
|
-
);
|
|
1822
|
-
|
|
1823
|
-
const prevSyncUnitsIds = prevSyncUnits.map(unit => unit.syncId);
|
|
1824
|
-
const syncUnitsIds = syncUnits.map(unit => unit.syncId);
|
|
1825
|
-
|
|
1826
|
-
const newSyncUnits = syncUnitsIds.filter(
|
|
1827
|
-
syncUnitId => !prevSyncUnitsIds.includes(syncUnitId)
|
|
1828
|
-
);
|
|
1829
|
-
|
|
1830
|
-
const removedSyncUnits = prevSyncUnitsIds.filter(
|
|
1831
|
-
syncUnitId => !syncUnitsIds.includes(syncUnitId)
|
|
1832
|
-
);
|
|
1833
|
-
|
|
1834
2052
|
// update listener cache
|
|
1835
2053
|
const lastOperation = operationsApplied
|
|
1836
2054
|
.filter(op => op.scope === 'global')
|
|
@@ -1857,6 +2075,8 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
1857
2075
|
? { type: 'local' }
|
|
1858
2076
|
: (options?.source ?? { type: 'local' });
|
|
1859
2077
|
|
|
2078
|
+
const operationSource = this.getOperationSource(source);
|
|
2079
|
+
|
|
1860
2080
|
this.listenerStateManager
|
|
1861
2081
|
.updateSynchronizationRevisions(
|
|
1862
2082
|
drive,
|
|
@@ -1874,29 +2094,18 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
1874
2094
|
],
|
|
1875
2095
|
source,
|
|
1876
2096
|
() => {
|
|
1877
|
-
this.
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
...newSyncUnits,
|
|
1881
|
-
...removedSyncUnits
|
|
1882
|
-
]) {
|
|
1883
|
-
this.updateSyncStatus(syncUnitId, 'SYNCING');
|
|
1884
|
-
}
|
|
2097
|
+
this.updateSyncUnitStatus(drive, {
|
|
2098
|
+
[operationSource]: 'SYNCING'
|
|
2099
|
+
});
|
|
1885
2100
|
},
|
|
1886
2101
|
this.handleListenerError.bind(this),
|
|
1887
2102
|
options?.forceSync ?? source.type === 'local'
|
|
1888
2103
|
)
|
|
1889
2104
|
.then(updates => {
|
|
1890
2105
|
if (updates.length) {
|
|
1891
|
-
this.
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
this.updateSyncStatus(syncUnitId, 'SUCCESS');
|
|
1895
|
-
}
|
|
1896
|
-
|
|
1897
|
-
for (const syncUnitId of removedSyncUnits) {
|
|
1898
|
-
this.updateSyncStatus(syncUnitId, null);
|
|
1899
|
-
}
|
|
2106
|
+
this.updateSyncUnitStatus(drive, {
|
|
2107
|
+
[operationSource]: 'SUCCESS'
|
|
2108
|
+
});
|
|
1900
2109
|
}
|
|
1901
2110
|
})
|
|
1902
2111
|
.catch(error => {
|
|
@@ -1904,18 +2113,11 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
1904
2113
|
'Non handled error updating sync revision',
|
|
1905
2114
|
error
|
|
1906
2115
|
);
|
|
1907
|
-
this.
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
]) {
|
|
1913
|
-
this.updateSyncStatus(
|
|
1914
|
-
syncUnitId,
|
|
1915
|
-
'ERROR',
|
|
1916
|
-
error as Error
|
|
1917
|
-
);
|
|
1918
|
-
}
|
|
2116
|
+
this.updateSyncUnitStatus(
|
|
2117
|
+
drive,
|
|
2118
|
+
{ [operationSource]: 'ERROR' },
|
|
2119
|
+
error as Error
|
|
2120
|
+
);
|
|
1919
2121
|
});
|
|
1920
2122
|
}
|
|
1921
2123
|
|
|
@@ -2108,7 +2310,7 @@ export class DocumentDriveServer extends BaseDocumentDriveServer {
|
|
|
2108
2310
|
logger.error(`Sync status not found for drive ${drive}`);
|
|
2109
2311
|
throw new Error(`Sync status not found for drive ${drive}`);
|
|
2110
2312
|
}
|
|
2111
|
-
return status;
|
|
2313
|
+
return this.getCombinedSyncUnitStatus(status);
|
|
2112
2314
|
}
|
|
2113
2315
|
|
|
2114
2316
|
on<K extends keyof DriveEvents>(event: K, cb: DriveEvents[K]): Unsubscribe {
|
|
@@ -56,6 +56,10 @@ export class ListenerManager extends BaseListenerManager {
|
|
|
56
56
|
return Promise.resolve(this.transmitters[driveId]?.[listenerId]);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
driveHasListeners(driveId: string) {
|
|
60
|
+
return this.listenerState.has(driveId);
|
|
61
|
+
}
|
|
62
|
+
|
|
59
63
|
async addListener(listener: Listener) {
|
|
60
64
|
const drive = listener.driveId;
|
|
61
65
|
|
package/src/server/types.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
State
|
|
21
21
|
} from 'document-model/document';
|
|
22
22
|
import { Unsubscribe } from 'nanoevents';
|
|
23
|
+
import { DriveInfo } from '../utils/graphql';
|
|
23
24
|
import { OperationError } from './error';
|
|
24
25
|
import {
|
|
25
26
|
ITransmitter,
|
|
@@ -36,6 +37,7 @@ export type RemoteDriveOptions = DocumentDriveLocalState & {
|
|
|
36
37
|
// TODO make local state optional
|
|
37
38
|
pullFilter?: ListenerFilter;
|
|
38
39
|
pullInterval?: number;
|
|
40
|
+
expectedDriveInfo?: DriveInfo;
|
|
39
41
|
};
|
|
40
42
|
|
|
41
43
|
export type CreateDocumentInput = CreateChildDocumentInput;
|
|
@@ -138,10 +140,38 @@ export type StrandUpdate = {
|
|
|
138
140
|
operations: OperationUpdate[];
|
|
139
141
|
};
|
|
140
142
|
|
|
141
|
-
export type SyncStatus = 'SYNCING' | UpdateStatus;
|
|
143
|
+
export type SyncStatus = 'INITIAL_SYNC' | 'SYNCING' | UpdateStatus;
|
|
144
|
+
|
|
145
|
+
export type PullSyncStatus = SyncStatus;
|
|
146
|
+
export type PushSyncStatus = SyncStatus;
|
|
147
|
+
|
|
148
|
+
export type SyncUnitStatusObject = {
|
|
149
|
+
push?: PushSyncStatus;
|
|
150
|
+
pull?: PullSyncStatus;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export type AddRemoteDriveStatus =
|
|
154
|
+
| 'SUCCESS'
|
|
155
|
+
| 'ERROR'
|
|
156
|
+
| 'PENDING'
|
|
157
|
+
| 'ADDING'
|
|
158
|
+
| 'ALREADY_ADDED';
|
|
142
159
|
|
|
143
160
|
export interface DriveEvents {
|
|
144
|
-
syncStatus: (
|
|
161
|
+
syncStatus: (
|
|
162
|
+
driveId: string,
|
|
163
|
+
status: SyncStatus,
|
|
164
|
+
error?: Error,
|
|
165
|
+
syncUnitStatus?: SyncUnitStatusObject
|
|
166
|
+
) => void;
|
|
167
|
+
defaultRemoteDrive: (
|
|
168
|
+
status: AddRemoteDriveStatus,
|
|
169
|
+
defaultDrives: Map<string, DefaultRemoteDriveInfo>,
|
|
170
|
+
driveInput: DefaultRemoteDriveInput,
|
|
171
|
+
driveId?: string,
|
|
172
|
+
driveName?: string,
|
|
173
|
+
error?: Error
|
|
174
|
+
) => void;
|
|
145
175
|
strandUpdate: (update: StrandUpdate) => void;
|
|
146
176
|
clientStrandsError: (
|
|
147
177
|
driveId: string,
|
|
@@ -168,6 +198,45 @@ export type AddOperationOptions = {
|
|
|
168
198
|
source: StrandUpdateSource;
|
|
169
199
|
};
|
|
170
200
|
|
|
201
|
+
export type DefaultRemoteDriveInput = {
|
|
202
|
+
url: string;
|
|
203
|
+
options: RemoteDriveOptions;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export type DefaultRemoteDriveInfo = DefaultRemoteDriveInput & {
|
|
207
|
+
status: AddRemoteDriveStatus;
|
|
208
|
+
metadata?: DriveInfo;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export type RemoveOldRemoteDrivesOption =
|
|
212
|
+
| {
|
|
213
|
+
strategy: 'remove-all';
|
|
214
|
+
}
|
|
215
|
+
| {
|
|
216
|
+
strategy: 'preserve-all';
|
|
217
|
+
}
|
|
218
|
+
| {
|
|
219
|
+
strategy: 'remove-by-id';
|
|
220
|
+
ids: string[];
|
|
221
|
+
}
|
|
222
|
+
| {
|
|
223
|
+
strategy: 'remove-by-url';
|
|
224
|
+
urls: string[];
|
|
225
|
+
}
|
|
226
|
+
| {
|
|
227
|
+
strategy: 'preserve-by-id';
|
|
228
|
+
ids: string[];
|
|
229
|
+
}
|
|
230
|
+
| {
|
|
231
|
+
strategy: 'preserve-by-url';
|
|
232
|
+
urls: string[];
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export type DocumentDriveServerOptions = {
|
|
236
|
+
defaultRemoteDrives?: Array<DefaultRemoteDriveInput>;
|
|
237
|
+
removeOldRemoteDrives?: RemoveOldRemoteDrivesOption;
|
|
238
|
+
};
|
|
239
|
+
|
|
171
240
|
export abstract class BaseDocumentDriveServer {
|
|
172
241
|
/** Public methods **/
|
|
173
242
|
abstract getDrives(): Promise<string[]>;
|
|
@@ -380,6 +449,7 @@ export abstract class BaseListenerManager {
|
|
|
380
449
|
abstract initDrive(drive: DocumentDriveDocument): Promise<void>;
|
|
381
450
|
abstract removeDrive(driveId: DocumentDriveState['id']): Promise<void>;
|
|
382
451
|
|
|
452
|
+
abstract driveHasListeners(driveId: string): boolean;
|
|
383
453
|
abstract addListener(listener: Listener): Promise<ITransmitter>;
|
|
384
454
|
abstract removeListener(
|
|
385
455
|
driveId: string,
|
package/src/storage/browser.ts
CHANGED
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
Operation,
|
|
7
7
|
OperationScope
|
|
8
8
|
} from 'document-model/document';
|
|
9
|
-
import {
|
|
9
|
+
import { DriveNotFoundError } from '../server/error';
|
|
10
|
+
import { SynchronizationUnitQuery } from '../server/types';
|
|
11
|
+
import { mergeOperations } from '../utils';
|
|
12
|
+
import { migrateDocumentOperationSigatures } from '../utils/migrations';
|
|
10
13
|
import { DocumentDriveStorage, DocumentStorage, IDriveStorage } from './types';
|
|
11
14
|
|
|
12
15
|
export class BrowserStorage implements IDriveStorage {
|
|
@@ -110,7 +113,7 @@ export class BrowserStorage implements IDriveStorage {
|
|
|
110
113
|
this.buildKey(BrowserStorage.DRIVES_KEY, id)
|
|
111
114
|
);
|
|
112
115
|
if (!drive) {
|
|
113
|
-
throw new
|
|
116
|
+
throw new DriveNotFoundError(id);
|
|
114
117
|
}
|
|
115
118
|
return drive;
|
|
116
119
|
}
|
|
@@ -214,4 +217,28 @@ export class BrowserStorage implements IDriveStorage {
|
|
|
214
217
|
return acc;
|
|
215
218
|
}, []);
|
|
216
219
|
}
|
|
220
|
+
|
|
221
|
+
// migrates all stored operations from legacy signature to signatures array
|
|
222
|
+
async migrateOperationSignatures() {
|
|
223
|
+
const drives = await this.getDrives();
|
|
224
|
+
for (const drive of drives) {
|
|
225
|
+
await this.migrateDocument(BrowserStorage.DRIVES_KEY, drive);
|
|
226
|
+
|
|
227
|
+
const documents = await this.getDocuments(drive);
|
|
228
|
+
await Promise.all(
|
|
229
|
+
documents.map(async docId => this.migrateDocument(drive, docId))
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async migrateDocument(drive: string, id: string) {
|
|
235
|
+
const document = await this.getDocument(drive, id);
|
|
236
|
+
const migratedDocument = migrateDocumentOperationSigatures(document);
|
|
237
|
+
if (migratedDocument !== document) {
|
|
238
|
+
return (await this.db).setItem(
|
|
239
|
+
this.buildKey(drive, id),
|
|
240
|
+
migratedDocument
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
217
244
|
}
|
|
@@ -17,6 +17,7 @@ import fs from 'fs/promises';
|
|
|
17
17
|
import stringify from 'json-stringify-deterministic';
|
|
18
18
|
import path from 'path';
|
|
19
19
|
import sanitize from 'sanitize-filename';
|
|
20
|
+
import { DriveNotFoundError } from '../server/error';
|
|
20
21
|
import type { SynchronizationUnitQuery } from '../server/types';
|
|
21
22
|
import { mergeOperations } from '../utils';
|
|
22
23
|
import { DocumentDriveStorage, DocumentStorage, IDriveStorage } from './types';
|
|
@@ -199,7 +200,7 @@ export class FilesystemStorage implements IDriveStorage {
|
|
|
199
200
|
id
|
|
200
201
|
)) as DocumentDriveStorage;
|
|
201
202
|
} catch {
|
|
202
|
-
throw new
|
|
203
|
+
throw new DriveNotFoundError(id);
|
|
203
204
|
}
|
|
204
205
|
}
|
|
205
206
|
|
package/src/storage/memory.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
Operation,
|
|
7
7
|
OperationScope
|
|
8
8
|
} from 'document-model/document';
|
|
9
|
+
import { DriveNotFoundError } from '../server/error';
|
|
9
10
|
import type { SynchronizationUnitQuery } from '../server/types';
|
|
10
11
|
import { mergeOperations } from '../utils';
|
|
11
12
|
import { DocumentDriveStorage, DocumentStorage, IDriveStorage } from './types';
|
|
@@ -31,7 +32,7 @@ export class MemoryStorage implements IDriveStorage {
|
|
|
31
32
|
async getDocument(driveId: string, id: string) {
|
|
32
33
|
const drive = this.documents[driveId];
|
|
33
34
|
if (!drive) {
|
|
34
|
-
throw new
|
|
35
|
+
throw new DriveNotFoundError(driveId);
|
|
35
36
|
}
|
|
36
37
|
const document = drive[id];
|
|
37
38
|
if (!document) {
|
|
@@ -102,7 +103,7 @@ export class MemoryStorage implements IDriveStorage {
|
|
|
102
103
|
|
|
103
104
|
async deleteDocument(drive: string, id: string) {
|
|
104
105
|
if (!this.documents[drive]) {
|
|
105
|
-
throw new
|
|
106
|
+
throw new DriveNotFoundError(drive);
|
|
106
107
|
}
|
|
107
108
|
delete this.documents[drive]![id];
|
|
108
109
|
}
|
|
@@ -114,7 +115,7 @@ export class MemoryStorage implements IDriveStorage {
|
|
|
114
115
|
async getDrive(id: string) {
|
|
115
116
|
const drive = this.drives[id];
|
|
116
117
|
if (!drive) {
|
|
117
|
-
throw new
|
|
118
|
+
throw new DriveNotFoundError(id);
|
|
118
119
|
}
|
|
119
120
|
return drive;
|
|
120
121
|
}
|
package/src/storage/prisma.ts
CHANGED
|
@@ -19,7 +19,7 @@ import type {
|
|
|
19
19
|
State
|
|
20
20
|
} from 'document-model/document';
|
|
21
21
|
import { IBackOffOptions, backOff } from 'exponential-backoff';
|
|
22
|
-
import { ConflictOperationError } from '../server/error';
|
|
22
|
+
import { ConflictOperationError, DriveNotFoundError } from '../server/error';
|
|
23
23
|
import type { SynchronizationUnitQuery } from '../server/types';
|
|
24
24
|
import { logger } from '../utils/logger';
|
|
25
25
|
import {
|
|
@@ -533,7 +533,7 @@ export class PrismaStorage implements IDriveStorage {
|
|
|
533
533
|
return doc as DocumentDriveStorage;
|
|
534
534
|
} catch (e) {
|
|
535
535
|
logger.error(e);
|
|
536
|
-
throw new
|
|
536
|
+
throw new DriveNotFoundError(id);
|
|
537
537
|
}
|
|
538
538
|
}
|
|
539
539
|
|
|
@@ -561,6 +561,13 @@ export class PrismaStorage implements IDriveStorage {
|
|
|
561
561
|
|
|
562
562
|
// delete drive document and its operations
|
|
563
563
|
await this.deleteDocument('drives', id);
|
|
564
|
+
|
|
565
|
+
// deletes all documents of the drive
|
|
566
|
+
await this.db.document.deleteMany({
|
|
567
|
+
where: {
|
|
568
|
+
driveId: id
|
|
569
|
+
}
|
|
570
|
+
});
|
|
564
571
|
}
|
|
565
572
|
|
|
566
573
|
async getOperationResultingState(
|
|
@@ -648,4 +655,22 @@ export class PrismaStorage implements IDriveStorage {
|
|
|
648
655
|
lastUpdated: new Date(row.lastUpdated).toISOString()
|
|
649
656
|
}));
|
|
650
657
|
}
|
|
658
|
+
|
|
659
|
+
// migrates all stored operations from legacy signature to signatures array
|
|
660
|
+
async migrateOperationSignatures() {
|
|
661
|
+
const count = await this.db.$executeRaw`
|
|
662
|
+
UPDATE "Operation"
|
|
663
|
+
SET context = jsonb_set(
|
|
664
|
+
context #- '{signer,signature}', -- Remove the old 'signature' field
|
|
665
|
+
'{signer,signatures}', -- Path to the new 'signatures' field
|
|
666
|
+
CASE
|
|
667
|
+
WHEN context->'signer'->>'signature' = '' THEN '[]'::jsonb
|
|
668
|
+
ELSE to_jsonb(array[context->'signer'->>'signature'])
|
|
669
|
+
END
|
|
670
|
+
)
|
|
671
|
+
WHERE context->'signer' ? 'signature' -- Check if the 'signature' key exists
|
|
672
|
+
`;
|
|
673
|
+
logger.info(`Migrated ${count} operations`);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
651
676
|
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BaseDocumentDriveServer,
|
|
3
|
+
DefaultRemoteDriveInfo,
|
|
4
|
+
DocumentDriveServerOptions,
|
|
5
|
+
DriveEvents,
|
|
6
|
+
RemoveOldRemoteDrivesOption
|
|
7
|
+
} from '../server';
|
|
8
|
+
import { DriveNotFoundError } from '../server/error';
|
|
9
|
+
import { requestPublicDrive } from './graphql';
|
|
10
|
+
import { logger } from './logger';
|
|
11
|
+
|
|
12
|
+
export interface IServerDelegateDrivesManager {
|
|
13
|
+
emit: (...args: Parameters<DriveEvents['defaultRemoteDrive']>) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class DefaultDrivesManager {
|
|
17
|
+
private defaultRemoteDrives = new Map<string, DefaultRemoteDriveInfo>();
|
|
18
|
+
private removeOldRemoteDrivesConfig: RemoveOldRemoteDrivesOption;
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private server: BaseDocumentDriveServer,
|
|
22
|
+
private delegate: IServerDelegateDrivesManager,
|
|
23
|
+
options?: Pick<
|
|
24
|
+
DocumentDriveServerOptions,
|
|
25
|
+
'defaultRemoteDrives' | 'removeOldRemoteDrives'
|
|
26
|
+
>
|
|
27
|
+
) {
|
|
28
|
+
if (options?.defaultRemoteDrives) {
|
|
29
|
+
for (const defaultDrive of options.defaultRemoteDrives) {
|
|
30
|
+
this.defaultRemoteDrives.set(defaultDrive.url, {
|
|
31
|
+
...defaultDrive,
|
|
32
|
+
status: 'PENDING'
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const strategyFromEnv =
|
|
38
|
+
process.env.DOCUMENT_DRIVE_OLD_REMOTE_DRIVES_STRATEGY;
|
|
39
|
+
const urlsFromEnv = process.env.DOCUMENT_DRIVE_OLD_REMOTE_DRIVES_URLS;
|
|
40
|
+
const idsFromEnv = process.env.DOCUMENT_DRIVE_OLD_REMOTE_DRIVES_IDS;
|
|
41
|
+
|
|
42
|
+
const strategy: RemoveOldRemoteDrivesOption['strategy'] =
|
|
43
|
+
strategyFromEnv !== undefined && strategyFromEnv !== ''
|
|
44
|
+
? (strategyFromEnv as RemoveOldRemoteDrivesOption['strategy'])
|
|
45
|
+
: 'preserve-all';
|
|
46
|
+
|
|
47
|
+
const urls =
|
|
48
|
+
urlsFromEnv !== undefined && urlsFromEnv !== ''
|
|
49
|
+
? urlsFromEnv.split(',')
|
|
50
|
+
: [];
|
|
51
|
+
|
|
52
|
+
const ids =
|
|
53
|
+
idsFromEnv !== undefined && idsFromEnv !== ''
|
|
54
|
+
? idsFromEnv.split(',')
|
|
55
|
+
: [];
|
|
56
|
+
|
|
57
|
+
this.removeOldRemoteDrivesConfig = options?.removeOldRemoteDrives || {
|
|
58
|
+
strategy,
|
|
59
|
+
urls,
|
|
60
|
+
ids
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
getDefaultRemoteDrives() {
|
|
65
|
+
return this.defaultRemoteDrives;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async deleteDriveById(driveId: string) {
|
|
69
|
+
try {
|
|
70
|
+
await this.server.deleteDrive(driveId);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (!(error instanceof DriveNotFoundError)) {
|
|
73
|
+
logger.error(error);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async preserveDrivesById(
|
|
79
|
+
drivesIdsToRemove: string[],
|
|
80
|
+
drives: string[]
|
|
81
|
+
) {
|
|
82
|
+
const getAllDrives = drives.map(driveId =>
|
|
83
|
+
this.server.getDrive(driveId)
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const drivesToRemove = (await Promise.all(getAllDrives))
|
|
87
|
+
.filter(
|
|
88
|
+
drive =>
|
|
89
|
+
drive.state.local.listeners.length > 0 ||
|
|
90
|
+
drive.state.local.triggers.length > 0
|
|
91
|
+
)
|
|
92
|
+
.filter(
|
|
93
|
+
drive => !drivesIdsToRemove.includes(drive.state.global.id)
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const driveIds = drivesToRemove.map(drive => drive.state.global.id);
|
|
97
|
+
await this.removeDrivesById(driveIds);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async removeDrivesById(driveIds: string[]) {
|
|
101
|
+
for (const driveId of driveIds) {
|
|
102
|
+
await this.deleteDriveById(driveId);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async removeOldremoteDrives() {
|
|
107
|
+
const driveids = await this.server.getDrives();
|
|
108
|
+
|
|
109
|
+
switch (this.removeOldRemoteDrivesConfig.strategy) {
|
|
110
|
+
case 'preserve-by-id': {
|
|
111
|
+
await this.preserveDrivesById(
|
|
112
|
+
this.removeOldRemoteDrivesConfig.ids,
|
|
113
|
+
driveids
|
|
114
|
+
);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case 'preserve-by-url': {
|
|
118
|
+
const getDrivesInfo = this.removeOldRemoteDrivesConfig.urls.map(
|
|
119
|
+
url => requestPublicDrive(url)
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const drivesIdsToPreserve = (
|
|
123
|
+
await Promise.all(getDrivesInfo)
|
|
124
|
+
).map(driveInfo => driveInfo.id);
|
|
125
|
+
|
|
126
|
+
await this.preserveDrivesById(drivesIdsToPreserve, driveids);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case 'remove-by-id': {
|
|
130
|
+
const drivesIdsToRemove =
|
|
131
|
+
this.removeOldRemoteDrivesConfig.ids.filter(driveId =>
|
|
132
|
+
driveids.includes(driveId)
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
await this.removeDrivesById(drivesIdsToRemove);
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
case 'remove-by-url': {
|
|
139
|
+
const getDrivesInfo = this.removeOldRemoteDrivesConfig.urls.map(
|
|
140
|
+
driveUrl => requestPublicDrive(driveUrl)
|
|
141
|
+
);
|
|
142
|
+
const drivesInfo = await Promise.all(getDrivesInfo);
|
|
143
|
+
|
|
144
|
+
const drivesIdsToRemove = drivesInfo
|
|
145
|
+
.map(driveInfo => driveInfo.id)
|
|
146
|
+
.filter(driveId => driveids.includes(driveId));
|
|
147
|
+
|
|
148
|
+
await this.removeDrivesById(drivesIdsToRemove);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case 'remove-all': {
|
|
152
|
+
const getDrives = driveids.map(driveId =>
|
|
153
|
+
this.server.getDrive(driveId)
|
|
154
|
+
);
|
|
155
|
+
const drives = await Promise.all(getDrives);
|
|
156
|
+
const drivesToRemove = drives
|
|
157
|
+
.filter(
|
|
158
|
+
drive =>
|
|
159
|
+
drive.state.local.listeners.length > 0 ||
|
|
160
|
+
drive.state.local.triggers.length > 0
|
|
161
|
+
)
|
|
162
|
+
.map(drive => drive.state.global.id);
|
|
163
|
+
|
|
164
|
+
await this.removeDrivesById(drivesToRemove);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async initializeDefaultRemoteDrives() {
|
|
171
|
+
const drives = await this.server.getDrives();
|
|
172
|
+
|
|
173
|
+
for (const remoteDrive of this.defaultRemoteDrives.values()) {
|
|
174
|
+
let remoteDriveInfo = { ...remoteDrive };
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const driveInfo = await requestPublicDrive(remoteDrive.url);
|
|
178
|
+
|
|
179
|
+
remoteDriveInfo = { ...remoteDrive, metadata: driveInfo };
|
|
180
|
+
|
|
181
|
+
this.defaultRemoteDrives.set(remoteDrive.url, remoteDriveInfo);
|
|
182
|
+
|
|
183
|
+
if (drives.includes(driveInfo.id)) {
|
|
184
|
+
remoteDriveInfo.status = 'ALREADY_ADDED';
|
|
185
|
+
|
|
186
|
+
this.defaultRemoteDrives.set(
|
|
187
|
+
remoteDrive.url,
|
|
188
|
+
remoteDriveInfo
|
|
189
|
+
);
|
|
190
|
+
this.delegate.emit(
|
|
191
|
+
'ALREADY_ADDED',
|
|
192
|
+
this.defaultRemoteDrives,
|
|
193
|
+
remoteDriveInfo,
|
|
194
|
+
driveInfo.id,
|
|
195
|
+
driveInfo.name
|
|
196
|
+
);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
remoteDriveInfo.status = 'ADDING';
|
|
201
|
+
|
|
202
|
+
this.defaultRemoteDrives.set(remoteDrive.url, remoteDriveInfo);
|
|
203
|
+
this.delegate.emit(
|
|
204
|
+
'ADDING',
|
|
205
|
+
this.defaultRemoteDrives,
|
|
206
|
+
remoteDriveInfo
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
await this.server.addRemoteDrive(remoteDrive.url, {
|
|
210
|
+
...remoteDrive.options,
|
|
211
|
+
expectedDriveInfo: driveInfo
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
remoteDriveInfo.status = 'SUCCESS';
|
|
215
|
+
|
|
216
|
+
this.defaultRemoteDrives.set(remoteDrive.url, remoteDriveInfo);
|
|
217
|
+
this.delegate.emit(
|
|
218
|
+
'SUCCESS',
|
|
219
|
+
this.defaultRemoteDrives,
|
|
220
|
+
remoteDriveInfo,
|
|
221
|
+
driveInfo.id,
|
|
222
|
+
driveInfo.name
|
|
223
|
+
);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
remoteDriveInfo.status = 'ERROR';
|
|
226
|
+
|
|
227
|
+
this.defaultRemoteDrives.set(remoteDrive.url, remoteDriveInfo);
|
|
228
|
+
this.delegate.emit(
|
|
229
|
+
'ERROR',
|
|
230
|
+
this.defaultRemoteDrives,
|
|
231
|
+
remoteDriveInfo,
|
|
232
|
+
undefined,
|
|
233
|
+
undefined,
|
|
234
|
+
error as Error
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Action,
|
|
3
|
+
Document,
|
|
4
|
+
DocumentOperations,
|
|
5
|
+
Operation,
|
|
6
|
+
OperationScope
|
|
7
|
+
} from 'document-model/document';
|
|
8
|
+
import { DocumentStorage } from '../storage/types';
|
|
9
|
+
|
|
10
|
+
export function migrateDocumentOperationSigatures<D extends Document>(
|
|
11
|
+
document: DocumentStorage<D>
|
|
12
|
+
): DocumentStorage<D> | undefined {
|
|
13
|
+
let legacy = false;
|
|
14
|
+
const operations = Object.entries(document.operations).reduce<
|
|
15
|
+
DocumentOperations<Action>
|
|
16
|
+
>(
|
|
17
|
+
(acc, [key, operations]) => {
|
|
18
|
+
const scope = key as unknown as OperationScope;
|
|
19
|
+
for (const op of operations) {
|
|
20
|
+
const newOp = migrateLegacyOperationSignature(op);
|
|
21
|
+
acc[scope].push(newOp);
|
|
22
|
+
if (newOp !== op) {
|
|
23
|
+
legacy = true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return acc;
|
|
27
|
+
},
|
|
28
|
+
{ global: [], local: [] }
|
|
29
|
+
);
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
31
|
+
return legacy ? { ...document, operations } : document;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function migrateLegacyOperationSignature<A extends Action>(
|
|
35
|
+
operation: Operation<A>
|
|
36
|
+
): Operation<A> {
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
38
|
+
if (!operation.context?.signer || operation.context.signer.signatures) {
|
|
39
|
+
return operation;
|
|
40
|
+
}
|
|
41
|
+
const { signer } = operation.context;
|
|
42
|
+
if ('signature' in signer) {
|
|
43
|
+
const signature = signer.signature as string | undefined;
|
|
44
|
+
return {
|
|
45
|
+
...operation,
|
|
46
|
+
context: {
|
|
47
|
+
...operation.context,
|
|
48
|
+
signer: {
|
|
49
|
+
user: signer.user,
|
|
50
|
+
app: signer.app,
|
|
51
|
+
signatures: signature?.length ? [signature] : []
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
} else {
|
|
56
|
+
return operation;
|
|
57
|
+
}
|
|
58
|
+
}
|