@verdant-web/server 1.7.8 → 1.7.9
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/dist/cjs/Baselines.d.ts +14 -14
- package/dist/cjs/Baselines.js +77 -77
- package/dist/cjs/ClientConnection.d.ts +18 -18
- package/dist/cjs/ClientConnection.js +105 -105
- package/dist/cjs/MessageSender.d.ts +5 -5
- package/dist/cjs/MessageSender.js +2 -2
- package/dist/cjs/OperationHistory.d.ts +34 -34
- package/dist/cjs/OperationHistory.js +108 -108
- package/dist/cjs/Presence.d.ts +15 -15
- package/dist/cjs/Presence.js +59 -59
- package/dist/cjs/Profiles.d.ts +10 -10
- package/dist/cjs/Profiles.js +19 -19
- package/dist/cjs/ReplicaKeepaliveTimers.d.ts +17 -17
- package/dist/cjs/ReplicaKeepaliveTimers.js +30 -30
- package/dist/cjs/Replicas.d.ts +23 -23
- package/dist/cjs/Replicas.js +132 -132
- package/dist/cjs/Server.d.ts +121 -121
- package/dist/cjs/Server.js +377 -377
- package/dist/cjs/ServerLibrary.d.ts +63 -63
- package/dist/cjs/ServerLibrary.js +467 -467
- package/dist/cjs/TokenProvider.d.ts +16 -16
- package/dist/cjs/TokenProvider.js +26 -26
- package/dist/cjs/TokenVerifier.d.ts +16 -16
- package/dist/cjs/TokenVerifier.js +28 -28
- package/dist/cjs/files/FileMetadata.d.ts +28 -28
- package/dist/cjs/files/FileMetadata.js +46 -46
- package/dist/cjs/files/FileStorage.d.ts +35 -35
- package/dist/cjs/files/FileStorage.js +71 -71
- package/dist/cjs/index.d.ts +9 -9
- package/dist/cjs/index.js +11 -11
- package/dist/cjs/migrations/v0.sql.d.ts +2 -2
- package/dist/cjs/migrations/v0.sql.js +3 -3
- package/dist/cjs/migrations/v1.sql.d.ts +2 -2
- package/dist/cjs/migrations/v1.sql.js +3 -3
- package/dist/cjs/migrations/v2.sql.d.ts +2 -2
- package/dist/cjs/migrations/v2.sql.js +4 -4
- package/dist/cjs/migrations/v3.sql.d.ts +2 -2
- package/dist/cjs/migrations/v3.sql.js +4 -4
- package/dist/cjs/migrations/v4.sql.d.ts +2 -2
- package/dist/cjs/migrations/v4.sql.js +4 -4
- package/dist/cjs/migrations.d.ts +2 -2
- package/dist/cjs/migrations.js +32 -32
- package/dist/cjs/types.d.ts +22 -22
- package/dist/cjs/types.js +2 -2
- package/dist/esm/Baselines.d.ts +14 -14
- package/dist/esm/Baselines.js +73 -73
- package/dist/esm/ClientConnection.d.ts +18 -18
- package/dist/esm/ClientConnection.js +101 -101
- package/dist/esm/MessageSender.d.ts +5 -5
- package/dist/esm/MessageSender.js +1 -1
- package/dist/esm/OperationHistory.d.ts +34 -34
- package/dist/esm/OperationHistory.js +104 -104
- package/dist/esm/Presence.d.ts +15 -15
- package/dist/esm/Presence.js +52 -52
- package/dist/esm/Profiles.d.ts +10 -10
- package/dist/esm/Profiles.js +15 -15
- package/dist/esm/ReplicaKeepaliveTimers.d.ts +17 -17
- package/dist/esm/ReplicaKeepaliveTimers.js +26 -26
- package/dist/esm/Replicas.d.ts +23 -23
- package/dist/esm/Replicas.js +128 -128
- package/dist/esm/Server.d.ts +121 -121
- package/dist/esm/Server.js +370 -370
- package/dist/esm/ServerLibrary.d.ts +63 -63
- package/dist/esm/ServerLibrary.js +463 -463
- package/dist/esm/TokenProvider.d.ts +16 -16
- package/dist/esm/TokenProvider.js +19 -19
- package/dist/esm/TokenVerifier.d.ts +16 -16
- package/dist/esm/TokenVerifier.js +21 -21
- package/dist/esm/files/FileMetadata.d.ts +28 -28
- package/dist/esm/files/FileMetadata.js +42 -42
- package/dist/esm/files/FileStorage.d.ts +35 -35
- package/dist/esm/files/FileStorage.js +44 -44
- package/dist/esm/index.d.ts +9 -9
- package/dist/esm/index.js +4 -4
- package/dist/esm/migrations/v0.sql.d.ts +2 -2
- package/dist/esm/migrations/v0.sql.js +1 -1
- package/dist/esm/migrations/v1.sql.d.ts +2 -2
- package/dist/esm/migrations/v1.sql.js +1 -1
- package/dist/esm/migrations/v2.sql.d.ts +2 -2
- package/dist/esm/migrations/v2.sql.js +2 -2
- package/dist/esm/migrations/v3.sql.d.ts +2 -2
- package/dist/esm/migrations/v3.sql.js +2 -2
- package/dist/esm/migrations/v4.sql.d.ts +2 -2
- package/dist/esm/migrations/v4.sql.js +2 -2
- package/dist/esm/migrations.d.ts +2 -2
- package/dist/esm/migrations.js +25 -25
- package/dist/esm/types.d.ts +22 -22
- package/dist/esm/types.js +1 -1
- package/dist/tsconfig-cjs.tsbuildinfo +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/dist/cjs/ServerStorage.d.ts +0 -35
- package/dist/cjs/ServerStorage.js +0 -60
- package/dist/cjs/ServerStorage.js.map +0 -1
- package/dist/esm/ServerStorage.d.ts +0 -44
- package/dist/esm/ServerStorage.js +0 -58
- package/dist/esm/ServerStorage.js.map +0 -1
|
@@ -1,464 +1,464 @@
|
|
|
1
|
-
import { applyOperations, omit, ReplicaType, isObjectRef, isFileRef, EventSubscriber, } from '@verdant-web/common';
|
|
2
|
-
import { ReplicaInfos } from './Replicas.js';
|
|
3
|
-
import { OperationHistory } from './OperationHistory.js';
|
|
4
|
-
import { Baselines } from './Baselines.js';
|
|
5
|
-
import { Presence } from './Presence.js';
|
|
6
|
-
export class ServerLibrary extends EventSubscriber {
|
|
7
|
-
constructor({ db, sender, profiles, replicaTruancyMinutes, log = () => { }, disableRebasing, fileMetadata, fileStorage, }) {
|
|
8
|
-
super();
|
|
9
|
-
this.presences = new Presence();
|
|
10
|
-
/**
|
|
11
|
-
* Validates a user's access to a replica. If the replica does not
|
|
12
|
-
* exist, a user may create it. But if it does exist, its user (clientId)
|
|
13
|
-
* must match the user making the request.
|
|
14
|
-
*/
|
|
15
|
-
this.validateReplicaAccess = (replicaId, info) => {
|
|
16
|
-
const replica = this.replicas.get(info.libraryId, replicaId);
|
|
17
|
-
if (replica && replica.clientId !== info.userId) {
|
|
18
|
-
throw new Error(`Replica ${replicaId} does not belong to client ${info.userId}`);
|
|
19
|
-
}
|
|
20
|
-
};
|
|
21
|
-
this.hasWriteAccess = (info) => {
|
|
22
|
-
return (info.type !== ReplicaType.ReadOnlyPull &&
|
|
23
|
-
info.type !== ReplicaType.ReadOnlyRealtime);
|
|
24
|
-
};
|
|
25
|
-
this.receive = async (message, clientKey, info) => {
|
|
26
|
-
this.validateReplicaAccess(message.replicaId, info);
|
|
27
|
-
switch (message.type) {
|
|
28
|
-
case 'op':
|
|
29
|
-
this.handleOperation(message, clientKey, info);
|
|
30
|
-
break;
|
|
31
|
-
case 'sync':
|
|
32
|
-
this.handleSync(message, clientKey, info);
|
|
33
|
-
break;
|
|
34
|
-
case 'ack':
|
|
35
|
-
this.handleAck(message, clientKey, info);
|
|
36
|
-
break;
|
|
37
|
-
case 'heartbeat':
|
|
38
|
-
this.handleHeartbeat(message, clientKey, info);
|
|
39
|
-
break;
|
|
40
|
-
case 'presence-update':
|
|
41
|
-
await this.handlePresenceUpdate(message, clientKey, info);
|
|
42
|
-
break;
|
|
43
|
-
default:
|
|
44
|
-
this.log('Unknown message type', message.type);
|
|
45
|
-
break;
|
|
46
|
-
}
|
|
47
|
-
this.replicas.updateLastSeen(info.libraryId, message.replicaId);
|
|
48
|
-
};
|
|
49
|
-
this.remove = (libraryId, replicaId) => {
|
|
50
|
-
this.presences.removeReplica(libraryId, replicaId);
|
|
51
|
-
};
|
|
52
|
-
this.handleOperation = (message, clientKey, info) => {
|
|
53
|
-
if (!message.operations.length)
|
|
54
|
-
return;
|
|
55
|
-
if (!this.hasWriteAccess(info)) {
|
|
56
|
-
this.sender.send(info.libraryId, clientKey, {
|
|
57
|
-
type: 'forbidden',
|
|
58
|
-
});
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
const run = this.db.transaction(() => {
|
|
62
|
-
// insert patches into history
|
|
63
|
-
this.operations.insertAll(info.libraryId, message.replicaId, message.operations);
|
|
64
|
-
});
|
|
65
|
-
run();
|
|
66
|
-
this.enqueueRebase(info.libraryId);
|
|
67
|
-
// rebroadcast to whole library except the sender
|
|
68
|
-
this.rebroadcastOperations(info.libraryId, clientKey, message.replicaId, message.operations, []);
|
|
69
|
-
// tell sender we got and processed their operation
|
|
70
|
-
this.sender.send(info.libraryId, clientKey, {
|
|
71
|
-
type: 'server-ack',
|
|
72
|
-
timestamp: message.timestamp,
|
|
73
|
-
});
|
|
74
|
-
this.updateHighwater(message.replicaId, message.operations[message.operations.length - 1].timestamp, info);
|
|
75
|
-
this.emit(`changes`, info, message.operations, []);
|
|
76
|
-
};
|
|
77
|
-
this.removeExtraOperationData = (operation) => {
|
|
78
|
-
return omit(operation, ['replicaId', 'serverOrder']);
|
|
79
|
-
};
|
|
80
|
-
this.rebroadcastOperations = (libraryId, clientKey, replicaId, ops, baselines) => {
|
|
81
|
-
if (ops.length === 0 && baselines.length === 0)
|
|
82
|
-
return;
|
|
83
|
-
this.log('info', 'Rebroadcasting', ops.length, 'operations and', baselines.length, 'baselines');
|
|
84
|
-
this.sender.broadcast(libraryId, {
|
|
85
|
-
type: 'op-re',
|
|
86
|
-
operations: ops,
|
|
87
|
-
baselines,
|
|
88
|
-
replicaId,
|
|
89
|
-
globalAckTimestamp: this.replicas.getGlobalAck(libraryId),
|
|
90
|
-
}, [clientKey]);
|
|
91
|
-
};
|
|
92
|
-
this.handleSync = (message, clientKey, info) => {
|
|
93
|
-
const replicaId = message.replicaId;
|
|
94
|
-
// TODO: is this right? shouldn't read-only replicas still be able to sync,
|
|
95
|
-
// just not send ops?
|
|
96
|
-
if (!this.hasWriteAccess(info)) {
|
|
97
|
-
this.sender.send(info.libraryId, clientKey, {
|
|
98
|
-
type: 'forbidden',
|
|
99
|
-
});
|
|
100
|
-
this.log('warning', 'sync from read-only replica', replicaId);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
if (message.resyncAll) {
|
|
104
|
-
// forget our local understanding of the replica and reset it
|
|
105
|
-
this.replicas.delete(info.libraryId, replicaId);
|
|
106
|
-
}
|
|
107
|
-
const { status, replicaInfo: clientReplicaInfo } = this.replicas.getOrCreate(info.libraryId, replicaId, info);
|
|
108
|
-
const changesSince = status === 'existing' ? clientReplicaInfo.ackedLogicalTime : null;
|
|
109
|
-
this.log(`Sync from ${replicaId} (user: ${info.userId}) [ackedLogicalTime: ${changesSince}, ackedServerOrder: ${clientReplicaInfo.ackedServerOrder}, status: ${status}}]`);
|
|
110
|
-
// lookup operations after the last ack the client gave us
|
|
111
|
-
const ops = this.operations.getFromServerOrder(info.libraryId, clientReplicaInfo.ackedServerOrder);
|
|
112
|
-
// new, unseen replicas should reset their existing storage
|
|
113
|
-
// to that of the server when joining a library.
|
|
114
|
-
// truant replicas should also reset their storage.
|
|
115
|
-
const replicaShouldReset = !!message.resyncAll || status !== 'existing';
|
|
116
|
-
// TODO: assumption: only new replicas need baselines
|
|
117
|
-
// const baselines = replicaShouldReset
|
|
118
|
-
// ? []
|
|
119
|
-
// : this.baselines.getAllAfter(info.libraryId, changesSince);
|
|
120
|
-
const baselines = this.baselines.getAllAfter(info.libraryId, changesSince);
|
|
121
|
-
// We detect that a library is new by checking if it has any history.
|
|
122
|
-
// If there are no existing operations or baselines (and the requested
|
|
123
|
-
// history timerange was "everything", i.e. "from null"), then this is
|
|
124
|
-
// a fresh library which should receive the first client's history as its
|
|
125
|
-
// own. Otherwise, if the client requested a reset, we should send them
|
|
126
|
-
// the full history of the library.
|
|
127
|
-
// the definition of this field could be improved to be more explicit
|
|
128
|
-
// and not rely on seemingly unrelated data.
|
|
129
|
-
const isEmptyLibrary = changesSince === null && ops.length === 0 && baselines.length === 0;
|
|
130
|
-
let overwriteLocalData = replicaShouldReset && !isEmptyLibrary;
|
|
131
|
-
// if the local library is empty and the replica is new to us,
|
|
132
|
-
// but the replica is providing a "since" timestamp, this
|
|
133
|
-
// suggests the local data is incomplete or gone. we request
|
|
134
|
-
// this replica should respond with a full history.
|
|
135
|
-
if (isEmptyLibrary && status === 'new' && message.since !== null) {
|
|
136
|
-
this.log('Detected local data is incomplete, requesting full history from replica', replicaId);
|
|
137
|
-
this.sender.send(info.libraryId, clientKey, {
|
|
138
|
-
type: 'need-since',
|
|
139
|
-
since: null,
|
|
140
|
-
});
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
else if (!isEmptyLibrary && message.since === null) {
|
|
144
|
-
// this would mean the replica is providing a full history
|
|
145
|
-
// but the library already has data. the replica should
|
|
146
|
-
// reset to the server version
|
|
147
|
-
this.log('Detected replica', replicaId, 'is providing a full history but the library already has data. Requesting replica reset.');
|
|
148
|
-
overwriteLocalData = true;
|
|
149
|
-
}
|
|
150
|
-
if (overwriteLocalData) {
|
|
151
|
-
this.log('Overwriting local data for replica', replicaId, 'with', baselines.length, 'baselines and', ops.length, 'operations');
|
|
152
|
-
}
|
|
153
|
-
// only write incoming replica data to storage if
|
|
154
|
-
// we are not overwriting the replica's data
|
|
155
|
-
if (!overwriteLocalData) {
|
|
156
|
-
// store all incoming operations and baselines
|
|
157
|
-
this.baselines.insertAll(info.libraryId, message.baselines);
|
|
158
|
-
this.log('Storing', message.baselines.length, 'baselines and', message.operations.length, 'operations');
|
|
159
|
-
this.operations.insertAll(info.libraryId, replicaId, message.operations);
|
|
160
|
-
this.replicas.updateAcknowledged(info.libraryId, replicaId, message.timestamp);
|
|
161
|
-
// will include the new global ack
|
|
162
|
-
this.rebroadcastOperations(info.libraryId, clientKey, message.replicaId, message.operations, message.baselines);
|
|
163
|
-
if (message.operations.length || message.baselines.length) {
|
|
164
|
-
this.emit(`changes`, info, message.operations, message.baselines);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
if (status === 'truant') {
|
|
168
|
-
this.log('A truant replica has reconnected', replicaId);
|
|
169
|
-
}
|
|
170
|
-
// create the nonce by encoding the server order of the last operation
|
|
171
|
-
const ackThisNonce = this.createAckNonce(ops);
|
|
172
|
-
// respond to client
|
|
173
|
-
this.log('Sending sync response with', ops.length, 'operations and', baselines.length, 'baselines');
|
|
174
|
-
this.sender.send(info.libraryId, clientKey, {
|
|
175
|
-
type: 'sync-resp',
|
|
176
|
-
operations: ops.map(this.removeExtraOperationData),
|
|
177
|
-
baselines: baselines.map((baseline) => ({
|
|
178
|
-
oid: baseline.oid,
|
|
179
|
-
snapshot: baseline.snapshot,
|
|
180
|
-
timestamp: baseline.timestamp,
|
|
181
|
-
})),
|
|
182
|
-
globalAckTimestamp: this.replicas.getGlobalAck(info.libraryId),
|
|
183
|
-
peerPresence: this.presences.all(info.libraryId),
|
|
184
|
-
// only request the client to overwrite local data if a reset is requested
|
|
185
|
-
// and there is data to overwrite it. otherwise the client may still
|
|
186
|
-
// send its own history to us.
|
|
187
|
-
overwriteLocalData,
|
|
188
|
-
ackedTimestamp: message.timestamp,
|
|
189
|
-
ackThisNonce,
|
|
190
|
-
});
|
|
191
|
-
};
|
|
192
|
-
this.createAckNonce = (ops) => {
|
|
193
|
-
return ops.length
|
|
194
|
-
? Buffer.from(JSON.stringify(ops[ops.length - 1].serverOrder)).toString('base64')
|
|
195
|
-
: undefined;
|
|
196
|
-
};
|
|
197
|
-
this.handleAck = (message, clientKey, info) => {
|
|
198
|
-
if (message.nonce) {
|
|
199
|
-
const decodedNonce = JSON.parse(Buffer.from(message.nonce, 'base64').toString('utf8'));
|
|
200
|
-
this.log('Ack from', message.replicaId, '(user', info.userId, ')', 'with order', decodedNonce);
|
|
201
|
-
if (typeof decodedNonce !== 'number') {
|
|
202
|
-
this.log('error', 'Invalid nonce', message.nonce);
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
const replicaId = message.replicaId;
|
|
206
|
-
this.replicas.updateServerOrder(info.libraryId, replicaId, decodedNonce);
|
|
207
|
-
}
|
|
208
|
-
else if (message.timestamp) {
|
|
209
|
-
this.replicas.acknowledgeOperation(info.libraryId, message.replicaId, message.timestamp);
|
|
210
|
-
const globalAck = this.replicas.getGlobalAck(info.libraryId);
|
|
211
|
-
if (globalAck) {
|
|
212
|
-
this.sender.broadcast(info.libraryId, {
|
|
213
|
-
type: 'global-ack',
|
|
214
|
-
timestamp: globalAck,
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
};
|
|
219
|
-
this.updateHighwater = (replicaId, timestamp, info, ignoreClientKeys = []) => {
|
|
220
|
-
this.replicas.updateAcknowledged(info.libraryId, replicaId, timestamp);
|
|
221
|
-
const newGlobalAck = this.replicas.getGlobalAck(info.libraryId);
|
|
222
|
-
if (newGlobalAck) {
|
|
223
|
-
this.sender.broadcast(info.libraryId, {
|
|
224
|
-
type: 'global-ack',
|
|
225
|
-
timestamp: newGlobalAck,
|
|
226
|
-
}, ignoreClientKeys);
|
|
227
|
-
}
|
|
228
|
-
};
|
|
229
|
-
this.pendingRebaseTimeout = null;
|
|
230
|
-
this.enqueueRebase = (libraryId) => {
|
|
231
|
-
if (this.disableRebasing)
|
|
232
|
-
return;
|
|
233
|
-
if (!this.pendingRebaseTimeout) {
|
|
234
|
-
setTimeout(() => this.rebase(libraryId), 0);
|
|
235
|
-
}
|
|
236
|
-
};
|
|
237
|
-
this.rebase = (libraryId) => {
|
|
238
|
-
if (this.disableRebasing)
|
|
239
|
-
return;
|
|
240
|
-
this.log('Performing rebase check');
|
|
241
|
-
// fundamentally a rebase occurs when some conditions are met:
|
|
242
|
-
// 1. the replica which created an operation has dropped that operation
|
|
243
|
-
// from their history stack, i.e. their oldest timestamp is after it.
|
|
244
|
-
// 2. all other replicas have acknowledged an operation since the
|
|
245
|
-
// operation which will be flattened to the baseline. i.e. global
|
|
246
|
-
// ack > timestamp.
|
|
247
|
-
//
|
|
248
|
-
// to determine which rebases we can do, we use a heuristic.
|
|
249
|
-
// the maximal set of operations we could potentially rebase is
|
|
250
|
-
// up to the newest 'oldest timestamp' of any replica. so we
|
|
251
|
-
// grab that slice of the operations history, then iterate over it
|
|
252
|
-
// and check if any rebase conditions are met.
|
|
253
|
-
// for global ack, to determine consensus, also allow
|
|
254
|
-
// for all actively connected replicas to ack regardless of their
|
|
255
|
-
// type.
|
|
256
|
-
const activeReplicaIds = Object.values(this.presences.all(libraryId)).map((p) => p.replicaId);
|
|
257
|
-
const globalAck = this.replicas.getGlobalAck(libraryId, activeReplicaIds);
|
|
258
|
-
if (!globalAck) {
|
|
259
|
-
this.log('No global ack, skipping rebase');
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
// these are in forward chronological order
|
|
263
|
-
const ops = this.operations.getBefore(libraryId, globalAck);
|
|
264
|
-
const opsToApply = {};
|
|
265
|
-
// if we encounter a sequential operation in history which does
|
|
266
|
-
// not meet our conditions, we must ignore subsequent operations
|
|
267
|
-
// applied to that document.
|
|
268
|
-
const hardStops = {};
|
|
269
|
-
for (const op of ops) {
|
|
270
|
-
const isBeforeGlobalAck = globalAck > op.timestamp;
|
|
271
|
-
if (!hardStops[op.oid] && isBeforeGlobalAck) {
|
|
272
|
-
opsToApply[op.oid] = opsToApply[op.oid] || [];
|
|
273
|
-
opsToApply[op.oid].push(op);
|
|
274
|
-
}
|
|
275
|
-
else {
|
|
276
|
-
hardStops[op.oid] = true;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
const deletedRefs = new Array();
|
|
280
|
-
for (const [documentId, ops] of Object.entries(opsToApply)) {
|
|
281
|
-
this.log('Rebasing', documentId);
|
|
282
|
-
this.baselines.applyOperations(libraryId, documentId, ops, deletedRefs),
|
|
283
|
-
this.operations.dropAll(libraryId, ops);
|
|
284
|
-
}
|
|
285
|
-
// hint to clients they can rebase too
|
|
286
|
-
this.sender.broadcast(libraryId, {
|
|
287
|
-
type: 'global-ack',
|
|
288
|
-
timestamp: globalAck,
|
|
289
|
-
});
|
|
290
|
-
// cleanup deleted files
|
|
291
|
-
for (const ref of deletedRefs) {
|
|
292
|
-
if (isFileRef(ref)) {
|
|
293
|
-
this.files.markPendingDelete(libraryId, ref.id);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
};
|
|
297
|
-
this.handleHeartbeat = (_, clientKey, info) => {
|
|
298
|
-
this.sender.send(info.libraryId, clientKey, {
|
|
299
|
-
type: 'heartbeat-response',
|
|
300
|
-
});
|
|
301
|
-
};
|
|
302
|
-
this.handlePresenceUpdate = async (message, clientKey, info) => {
|
|
303
|
-
this.presences.set(info.libraryId, info.userId, {
|
|
304
|
-
presence: message.presence,
|
|
305
|
-
replicaId: message.replicaId,
|
|
306
|
-
profile: await this.profiles.get(info.userId),
|
|
307
|
-
id: info.userId,
|
|
308
|
-
});
|
|
309
|
-
this.sender.broadcast(info.libraryId, {
|
|
310
|
-
type: 'presence-changed',
|
|
311
|
-
replicaId: message.replicaId,
|
|
312
|
-
userInfo: {
|
|
313
|
-
id: info.userId,
|
|
314
|
-
presence: message.presence,
|
|
315
|
-
profile: await this.profiles.get(info.userId),
|
|
316
|
-
replicaId: message.replicaId,
|
|
317
|
-
},
|
|
318
|
-
},
|
|
319
|
-
// mirror back to the replica which sent it so it has profile
|
|
320
|
-
[]);
|
|
321
|
-
};
|
|
322
|
-
this.onPresenceLost = async (libraryId, replicaId, userId) => {
|
|
323
|
-
this.log('User disconnected from all replicas:', userId);
|
|
324
|
-
this.sender.broadcast(libraryId, {
|
|
325
|
-
type: 'presence-offline',
|
|
326
|
-
replicaId,
|
|
327
|
-
userId,
|
|
328
|
-
});
|
|
329
|
-
if (Object.keys(this.presences.all(libraryId)).length === 0) {
|
|
330
|
-
this.log(`All users have disconnected from ${libraryId}`);
|
|
331
|
-
// could happen - if the server is shutting down manually
|
|
332
|
-
if (!this.db.open) {
|
|
333
|
-
this.log('debug', 'Database not open, skipping cleanup');
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
const pendingDelete = this.files.getPendingDeletes(libraryId);
|
|
337
|
-
if (pendingDelete.length > 0) {
|
|
338
|
-
if (!this.fileStorage) {
|
|
339
|
-
throw new Error('File storage not configured, cannot delete files');
|
|
340
|
-
}
|
|
341
|
-
this.log('Deleting files:', pendingDelete.map((f) => f.fileId).join(', '));
|
|
342
|
-
await Promise.all(pendingDelete.map(async (fileInfo) => {
|
|
343
|
-
var _a;
|
|
344
|
-
try {
|
|
345
|
-
await ((_a = this.fileStorage) === null || _a === void 0 ? void 0 : _a.delete({
|
|
346
|
-
libraryId: fileInfo.libraryId,
|
|
347
|
-
fileName: fileInfo.name,
|
|
348
|
-
id: fileInfo.fileId,
|
|
349
|
-
type: fileInfo.type,
|
|
350
|
-
}));
|
|
351
|
-
this.files.delete(libraryId, fileInfo.fileId);
|
|
352
|
-
}
|
|
353
|
-
catch (e) {
|
|
354
|
-
this.log('Failed to delete file', fileInfo.fileId, ' the file will remain in a pending delete state', e);
|
|
355
|
-
}
|
|
356
|
-
}));
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
};
|
|
360
|
-
this.destroy = async (libraryId) => {
|
|
361
|
-
var _a;
|
|
362
|
-
this.presences.clear(libraryId);
|
|
363
|
-
this.replicas.deleteAll(libraryId);
|
|
364
|
-
this.operations.deleteAll(libraryId);
|
|
365
|
-
this.baselines.deleteAll(libraryId);
|
|
366
|
-
const allFiles = this.files.getAll(libraryId);
|
|
367
|
-
if (allFiles.length > 0) {
|
|
368
|
-
this.log(`Deleting ${allFiles.length} files for library ${libraryId}`, allFiles.map((f) => f.fileId).join(', '));
|
|
369
|
-
for (const fileInfo of allFiles) {
|
|
370
|
-
await ((_a = this.fileStorage) === null || _a === void 0 ? void 0 : _a.delete({
|
|
371
|
-
libraryId,
|
|
372
|
-
fileName: fileInfo.name,
|
|
373
|
-
id: fileInfo.fileId,
|
|
374
|
-
type: fileInfo.type,
|
|
375
|
-
}));
|
|
376
|
-
}
|
|
377
|
-
this.files.deleteAll(libraryId);
|
|
378
|
-
}
|
|
379
|
-
};
|
|
380
|
-
this.getPresence = (libraryId) => {
|
|
381
|
-
return this.presences.all(libraryId);
|
|
382
|
-
};
|
|
383
|
-
this.evictUser = (libraryId, userId) => {
|
|
384
|
-
this.replicas.deleteAllForUser(libraryId, userId);
|
|
385
|
-
};
|
|
386
|
-
this.getDocumentSnapshot = (libraryId, oid) => {
|
|
387
|
-
return this.hydrateObject(libraryId, oid);
|
|
388
|
-
};
|
|
389
|
-
this.getObjectSnapshot = (libraryId, oid) => {
|
|
390
|
-
var _a;
|
|
391
|
-
const baseline = this.baselines.get(libraryId, oid);
|
|
392
|
-
const ops = this.operations.getAllFor(libraryId, oid);
|
|
393
|
-
const snapshot = applyOperations((_a = baseline === null || baseline === void 0 ? void 0 : baseline.snapshot) !== null && _a !== void 0 ? _a : undefined, ops);
|
|
394
|
-
return snapshot;
|
|
395
|
-
};
|
|
396
|
-
this.hydrateObject = (libraryId, oid) => {
|
|
397
|
-
const snapshot = this.getObjectSnapshot(libraryId, oid);
|
|
398
|
-
if (Array.isArray(snapshot)) {
|
|
399
|
-
return snapshot.map((item, index) => {
|
|
400
|
-
if (isObjectRef(item)) {
|
|
401
|
-
return this.hydrateObject(libraryId, item.id);
|
|
402
|
-
}
|
|
403
|
-
else if (isFileRef(item)) {
|
|
404
|
-
return this.hydrateFile(libraryId, item.id);
|
|
405
|
-
}
|
|
406
|
-
else {
|
|
407
|
-
return item;
|
|
408
|
-
}
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
else if (snapshot && typeof snapshot === 'object') {
|
|
412
|
-
const hydrated = Object.assign({}, snapshot);
|
|
413
|
-
for (const [key, value] of Object.entries(snapshot)) {
|
|
414
|
-
if (isObjectRef(value)) {
|
|
415
|
-
hydrated[key] = this.hydrateObject(libraryId, value.id);
|
|
416
|
-
}
|
|
417
|
-
else if (isFileRef(value)) {
|
|
418
|
-
hydrated[key] = this.hydrateFile(libraryId, value.id);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
return hydrated;
|
|
422
|
-
}
|
|
423
|
-
else {
|
|
424
|
-
return snapshot;
|
|
425
|
-
}
|
|
426
|
-
};
|
|
427
|
-
this.hydrateFile = (libraryId, fileId) => {
|
|
428
|
-
var _a, _b;
|
|
429
|
-
const data = this.files.get(libraryId, fileId);
|
|
430
|
-
if (data) {
|
|
431
|
-
const url = (_b = (_a = this.fileStorage) === null || _a === void 0 ? void 0 : _a.getUrl({
|
|
432
|
-
fileName: data.name,
|
|
433
|
-
id: data.fileId,
|
|
434
|
-
libraryId,
|
|
435
|
-
type: data.type,
|
|
436
|
-
})) !== null && _b !== void 0 ? _b : null;
|
|
437
|
-
return {
|
|
438
|
-
url,
|
|
439
|
-
id: data.fileId,
|
|
440
|
-
name: data.name,
|
|
441
|
-
type: data.type,
|
|
442
|
-
};
|
|
443
|
-
}
|
|
444
|
-
else {
|
|
445
|
-
return null;
|
|
446
|
-
}
|
|
447
|
-
};
|
|
448
|
-
this.close = () => {
|
|
449
|
-
this.db.close();
|
|
450
|
-
};
|
|
451
|
-
this.db = db;
|
|
452
|
-
this.log = log;
|
|
453
|
-
this.disableRebasing = !!disableRebasing;
|
|
454
|
-
this.sender = sender;
|
|
455
|
-
this.profiles = profiles;
|
|
456
|
-
this.replicas = new ReplicaInfos(this.db, replicaTruancyMinutes);
|
|
457
|
-
this.operations = new OperationHistory(this.db);
|
|
458
|
-
this.baselines = new Baselines(this.db);
|
|
459
|
-
this.files = fileMetadata;
|
|
460
|
-
this.fileStorage = fileStorage;
|
|
461
|
-
this.presences.on('lost', this.onPresenceLost);
|
|
462
|
-
}
|
|
463
|
-
}
|
|
1
|
+
import { applyOperations, omit, ReplicaType, isObjectRef, isFileRef, EventSubscriber, } from '@verdant-web/common';
|
|
2
|
+
import { ReplicaInfos } from './Replicas.js';
|
|
3
|
+
import { OperationHistory } from './OperationHistory.js';
|
|
4
|
+
import { Baselines } from './Baselines.js';
|
|
5
|
+
import { Presence } from './Presence.js';
|
|
6
|
+
export class ServerLibrary extends EventSubscriber {
|
|
7
|
+
constructor({ db, sender, profiles, replicaTruancyMinutes, log = () => { }, disableRebasing, fileMetadata, fileStorage, }) {
|
|
8
|
+
super();
|
|
9
|
+
this.presences = new Presence();
|
|
10
|
+
/**
|
|
11
|
+
* Validates a user's access to a replica. If the replica does not
|
|
12
|
+
* exist, a user may create it. But if it does exist, its user (clientId)
|
|
13
|
+
* must match the user making the request.
|
|
14
|
+
*/
|
|
15
|
+
this.validateReplicaAccess = (replicaId, info) => {
|
|
16
|
+
const replica = this.replicas.get(info.libraryId, replicaId);
|
|
17
|
+
if (replica && replica.clientId !== info.userId) {
|
|
18
|
+
throw new Error(`Replica ${replicaId} does not belong to client ${info.userId}`);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
this.hasWriteAccess = (info) => {
|
|
22
|
+
return (info.type !== ReplicaType.ReadOnlyPull &&
|
|
23
|
+
info.type !== ReplicaType.ReadOnlyRealtime);
|
|
24
|
+
};
|
|
25
|
+
this.receive = async (message, clientKey, info) => {
|
|
26
|
+
this.validateReplicaAccess(message.replicaId, info);
|
|
27
|
+
switch (message.type) {
|
|
28
|
+
case 'op':
|
|
29
|
+
this.handleOperation(message, clientKey, info);
|
|
30
|
+
break;
|
|
31
|
+
case 'sync':
|
|
32
|
+
this.handleSync(message, clientKey, info);
|
|
33
|
+
break;
|
|
34
|
+
case 'ack':
|
|
35
|
+
this.handleAck(message, clientKey, info);
|
|
36
|
+
break;
|
|
37
|
+
case 'heartbeat':
|
|
38
|
+
this.handleHeartbeat(message, clientKey, info);
|
|
39
|
+
break;
|
|
40
|
+
case 'presence-update':
|
|
41
|
+
await this.handlePresenceUpdate(message, clientKey, info);
|
|
42
|
+
break;
|
|
43
|
+
default:
|
|
44
|
+
this.log('Unknown message type', message.type);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
this.replicas.updateLastSeen(info.libraryId, message.replicaId);
|
|
48
|
+
};
|
|
49
|
+
this.remove = (libraryId, replicaId) => {
|
|
50
|
+
this.presences.removeReplica(libraryId, replicaId);
|
|
51
|
+
};
|
|
52
|
+
this.handleOperation = (message, clientKey, info) => {
|
|
53
|
+
if (!message.operations.length)
|
|
54
|
+
return;
|
|
55
|
+
if (!this.hasWriteAccess(info)) {
|
|
56
|
+
this.sender.send(info.libraryId, clientKey, {
|
|
57
|
+
type: 'forbidden',
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const run = this.db.transaction(() => {
|
|
62
|
+
// insert patches into history
|
|
63
|
+
this.operations.insertAll(info.libraryId, message.replicaId, message.operations);
|
|
64
|
+
});
|
|
65
|
+
run();
|
|
66
|
+
this.enqueueRebase(info.libraryId);
|
|
67
|
+
// rebroadcast to whole library except the sender
|
|
68
|
+
this.rebroadcastOperations(info.libraryId, clientKey, message.replicaId, message.operations, []);
|
|
69
|
+
// tell sender we got and processed their operation
|
|
70
|
+
this.sender.send(info.libraryId, clientKey, {
|
|
71
|
+
type: 'server-ack',
|
|
72
|
+
timestamp: message.timestamp,
|
|
73
|
+
});
|
|
74
|
+
this.updateHighwater(message.replicaId, message.operations[message.operations.length - 1].timestamp, info);
|
|
75
|
+
this.emit(`changes`, info, message.operations, []);
|
|
76
|
+
};
|
|
77
|
+
this.removeExtraOperationData = (operation) => {
|
|
78
|
+
return omit(operation, ['replicaId', 'serverOrder']);
|
|
79
|
+
};
|
|
80
|
+
this.rebroadcastOperations = (libraryId, clientKey, replicaId, ops, baselines) => {
|
|
81
|
+
if (ops.length === 0 && baselines.length === 0)
|
|
82
|
+
return;
|
|
83
|
+
this.log('info', 'Rebroadcasting', ops.length, 'operations and', baselines.length, 'baselines');
|
|
84
|
+
this.sender.broadcast(libraryId, {
|
|
85
|
+
type: 'op-re',
|
|
86
|
+
operations: ops,
|
|
87
|
+
baselines,
|
|
88
|
+
replicaId,
|
|
89
|
+
globalAckTimestamp: this.replicas.getGlobalAck(libraryId),
|
|
90
|
+
}, [clientKey]);
|
|
91
|
+
};
|
|
92
|
+
this.handleSync = (message, clientKey, info) => {
|
|
93
|
+
const replicaId = message.replicaId;
|
|
94
|
+
// TODO: is this right? shouldn't read-only replicas still be able to sync,
|
|
95
|
+
// just not send ops?
|
|
96
|
+
if (!this.hasWriteAccess(info)) {
|
|
97
|
+
this.sender.send(info.libraryId, clientKey, {
|
|
98
|
+
type: 'forbidden',
|
|
99
|
+
});
|
|
100
|
+
this.log('warning', 'sync from read-only replica', replicaId);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (message.resyncAll) {
|
|
104
|
+
// forget our local understanding of the replica and reset it
|
|
105
|
+
this.replicas.delete(info.libraryId, replicaId);
|
|
106
|
+
}
|
|
107
|
+
const { status, replicaInfo: clientReplicaInfo } = this.replicas.getOrCreate(info.libraryId, replicaId, info);
|
|
108
|
+
const changesSince = status === 'existing' ? clientReplicaInfo.ackedLogicalTime : null;
|
|
109
|
+
this.log(`Sync from ${replicaId} (user: ${info.userId}) [ackedLogicalTime: ${changesSince}, ackedServerOrder: ${clientReplicaInfo.ackedServerOrder}, status: ${status}}]`);
|
|
110
|
+
// lookup operations after the last ack the client gave us
|
|
111
|
+
const ops = this.operations.getFromServerOrder(info.libraryId, clientReplicaInfo.ackedServerOrder);
|
|
112
|
+
// new, unseen replicas should reset their existing storage
|
|
113
|
+
// to that of the server when joining a library.
|
|
114
|
+
// truant replicas should also reset their storage.
|
|
115
|
+
const replicaShouldReset = !!message.resyncAll || status !== 'existing';
|
|
116
|
+
// TODO: assumption: only new replicas need baselines
|
|
117
|
+
// const baselines = replicaShouldReset
|
|
118
|
+
// ? []
|
|
119
|
+
// : this.baselines.getAllAfter(info.libraryId, changesSince);
|
|
120
|
+
const baselines = this.baselines.getAllAfter(info.libraryId, changesSince);
|
|
121
|
+
// We detect that a library is new by checking if it has any history.
|
|
122
|
+
// If there are no existing operations or baselines (and the requested
|
|
123
|
+
// history timerange was "everything", i.e. "from null"), then this is
|
|
124
|
+
// a fresh library which should receive the first client's history as its
|
|
125
|
+
// own. Otherwise, if the client requested a reset, we should send them
|
|
126
|
+
// the full history of the library.
|
|
127
|
+
// the definition of this field could be improved to be more explicit
|
|
128
|
+
// and not rely on seemingly unrelated data.
|
|
129
|
+
const isEmptyLibrary = changesSince === null && ops.length === 0 && baselines.length === 0;
|
|
130
|
+
let overwriteLocalData = replicaShouldReset && !isEmptyLibrary;
|
|
131
|
+
// if the local library is empty and the replica is new to us,
|
|
132
|
+
// but the replica is providing a "since" timestamp, this
|
|
133
|
+
// suggests the local data is incomplete or gone. we request
|
|
134
|
+
// this replica should respond with a full history.
|
|
135
|
+
if (isEmptyLibrary && status === 'new' && message.since !== null) {
|
|
136
|
+
this.log('Detected local data is incomplete, requesting full history from replica', replicaId);
|
|
137
|
+
this.sender.send(info.libraryId, clientKey, {
|
|
138
|
+
type: 'need-since',
|
|
139
|
+
since: null,
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
else if (!isEmptyLibrary && message.since === null) {
|
|
144
|
+
// this would mean the replica is providing a full history
|
|
145
|
+
// but the library already has data. the replica should
|
|
146
|
+
// reset to the server version
|
|
147
|
+
this.log('Detected replica', replicaId, 'is providing a full history but the library already has data. Requesting replica reset.');
|
|
148
|
+
overwriteLocalData = true;
|
|
149
|
+
}
|
|
150
|
+
if (overwriteLocalData) {
|
|
151
|
+
this.log('Overwriting local data for replica', replicaId, 'with', baselines.length, 'baselines and', ops.length, 'operations');
|
|
152
|
+
}
|
|
153
|
+
// only write incoming replica data to storage if
|
|
154
|
+
// we are not overwriting the replica's data
|
|
155
|
+
if (!overwriteLocalData) {
|
|
156
|
+
// store all incoming operations and baselines
|
|
157
|
+
this.baselines.insertAll(info.libraryId, message.baselines);
|
|
158
|
+
this.log('Storing', message.baselines.length, 'baselines and', message.operations.length, 'operations');
|
|
159
|
+
this.operations.insertAll(info.libraryId, replicaId, message.operations);
|
|
160
|
+
this.replicas.updateAcknowledged(info.libraryId, replicaId, message.timestamp);
|
|
161
|
+
// will include the new global ack
|
|
162
|
+
this.rebroadcastOperations(info.libraryId, clientKey, message.replicaId, message.operations, message.baselines);
|
|
163
|
+
if (message.operations.length || message.baselines.length) {
|
|
164
|
+
this.emit(`changes`, info, message.operations, message.baselines);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (status === 'truant') {
|
|
168
|
+
this.log('A truant replica has reconnected', replicaId);
|
|
169
|
+
}
|
|
170
|
+
// create the nonce by encoding the server order of the last operation
|
|
171
|
+
const ackThisNonce = this.createAckNonce(ops);
|
|
172
|
+
// respond to client
|
|
173
|
+
this.log('Sending sync response with', ops.length, 'operations and', baselines.length, 'baselines');
|
|
174
|
+
this.sender.send(info.libraryId, clientKey, {
|
|
175
|
+
type: 'sync-resp',
|
|
176
|
+
operations: ops.map(this.removeExtraOperationData),
|
|
177
|
+
baselines: baselines.map((baseline) => ({
|
|
178
|
+
oid: baseline.oid,
|
|
179
|
+
snapshot: baseline.snapshot,
|
|
180
|
+
timestamp: baseline.timestamp,
|
|
181
|
+
})),
|
|
182
|
+
globalAckTimestamp: this.replicas.getGlobalAck(info.libraryId),
|
|
183
|
+
peerPresence: this.presences.all(info.libraryId),
|
|
184
|
+
// only request the client to overwrite local data if a reset is requested
|
|
185
|
+
// and there is data to overwrite it. otherwise the client may still
|
|
186
|
+
// send its own history to us.
|
|
187
|
+
overwriteLocalData,
|
|
188
|
+
ackedTimestamp: message.timestamp,
|
|
189
|
+
ackThisNonce,
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
this.createAckNonce = (ops) => {
|
|
193
|
+
return ops.length
|
|
194
|
+
? Buffer.from(JSON.stringify(ops[ops.length - 1].serverOrder)).toString('base64')
|
|
195
|
+
: undefined;
|
|
196
|
+
};
|
|
197
|
+
this.handleAck = (message, clientKey, info) => {
|
|
198
|
+
if (message.nonce) {
|
|
199
|
+
const decodedNonce = JSON.parse(Buffer.from(message.nonce, 'base64').toString('utf8'));
|
|
200
|
+
this.log('Ack from', message.replicaId, '(user', info.userId, ')', 'with order', decodedNonce);
|
|
201
|
+
if (typeof decodedNonce !== 'number') {
|
|
202
|
+
this.log('error', 'Invalid nonce', message.nonce);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const replicaId = message.replicaId;
|
|
206
|
+
this.replicas.updateServerOrder(info.libraryId, replicaId, decodedNonce);
|
|
207
|
+
}
|
|
208
|
+
else if (message.timestamp) {
|
|
209
|
+
this.replicas.acknowledgeOperation(info.libraryId, message.replicaId, message.timestamp);
|
|
210
|
+
const globalAck = this.replicas.getGlobalAck(info.libraryId);
|
|
211
|
+
if (globalAck) {
|
|
212
|
+
this.sender.broadcast(info.libraryId, {
|
|
213
|
+
type: 'global-ack',
|
|
214
|
+
timestamp: globalAck,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
this.updateHighwater = (replicaId, timestamp, info, ignoreClientKeys = []) => {
|
|
220
|
+
this.replicas.updateAcknowledged(info.libraryId, replicaId, timestamp);
|
|
221
|
+
const newGlobalAck = this.replicas.getGlobalAck(info.libraryId);
|
|
222
|
+
if (newGlobalAck) {
|
|
223
|
+
this.sender.broadcast(info.libraryId, {
|
|
224
|
+
type: 'global-ack',
|
|
225
|
+
timestamp: newGlobalAck,
|
|
226
|
+
}, ignoreClientKeys);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
this.pendingRebaseTimeout = null;
|
|
230
|
+
this.enqueueRebase = (libraryId) => {
|
|
231
|
+
if (this.disableRebasing)
|
|
232
|
+
return;
|
|
233
|
+
if (!this.pendingRebaseTimeout) {
|
|
234
|
+
setTimeout(() => this.rebase(libraryId), 0);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
this.rebase = (libraryId) => {
|
|
238
|
+
if (this.disableRebasing)
|
|
239
|
+
return;
|
|
240
|
+
this.log('Performing rebase check');
|
|
241
|
+
// fundamentally a rebase occurs when some conditions are met:
|
|
242
|
+
// 1. the replica which created an operation has dropped that operation
|
|
243
|
+
// from their history stack, i.e. their oldest timestamp is after it.
|
|
244
|
+
// 2. all other replicas have acknowledged an operation since the
|
|
245
|
+
// operation which will be flattened to the baseline. i.e. global
|
|
246
|
+
// ack > timestamp.
|
|
247
|
+
//
|
|
248
|
+
// to determine which rebases we can do, we use a heuristic.
|
|
249
|
+
// the maximal set of operations we could potentially rebase is
|
|
250
|
+
// up to the newest 'oldest timestamp' of any replica. so we
|
|
251
|
+
// grab that slice of the operations history, then iterate over it
|
|
252
|
+
// and check if any rebase conditions are met.
|
|
253
|
+
// for global ack, to determine consensus, also allow
|
|
254
|
+
// for all actively connected replicas to ack regardless of their
|
|
255
|
+
// type.
|
|
256
|
+
const activeReplicaIds = Object.values(this.presences.all(libraryId)).map((p) => p.replicaId);
|
|
257
|
+
const globalAck = this.replicas.getGlobalAck(libraryId, activeReplicaIds);
|
|
258
|
+
if (!globalAck) {
|
|
259
|
+
this.log('No global ack, skipping rebase');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
// these are in forward chronological order
|
|
263
|
+
const ops = this.operations.getBefore(libraryId, globalAck);
|
|
264
|
+
const opsToApply = {};
|
|
265
|
+
// if we encounter a sequential operation in history which does
|
|
266
|
+
// not meet our conditions, we must ignore subsequent operations
|
|
267
|
+
// applied to that document.
|
|
268
|
+
const hardStops = {};
|
|
269
|
+
for (const op of ops) {
|
|
270
|
+
const isBeforeGlobalAck = globalAck > op.timestamp;
|
|
271
|
+
if (!hardStops[op.oid] && isBeforeGlobalAck) {
|
|
272
|
+
opsToApply[op.oid] = opsToApply[op.oid] || [];
|
|
273
|
+
opsToApply[op.oid].push(op);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
hardStops[op.oid] = true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const deletedRefs = new Array();
|
|
280
|
+
for (const [documentId, ops] of Object.entries(opsToApply)) {
|
|
281
|
+
this.log('Rebasing', documentId);
|
|
282
|
+
this.baselines.applyOperations(libraryId, documentId, ops, deletedRefs),
|
|
283
|
+
this.operations.dropAll(libraryId, ops);
|
|
284
|
+
}
|
|
285
|
+
// hint to clients they can rebase too
|
|
286
|
+
this.sender.broadcast(libraryId, {
|
|
287
|
+
type: 'global-ack',
|
|
288
|
+
timestamp: globalAck,
|
|
289
|
+
});
|
|
290
|
+
// cleanup deleted files
|
|
291
|
+
for (const ref of deletedRefs) {
|
|
292
|
+
if (isFileRef(ref)) {
|
|
293
|
+
this.files.markPendingDelete(libraryId, ref.id);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
this.handleHeartbeat = (_, clientKey, info) => {
|
|
298
|
+
this.sender.send(info.libraryId, clientKey, {
|
|
299
|
+
type: 'heartbeat-response',
|
|
300
|
+
});
|
|
301
|
+
};
|
|
302
|
+
this.handlePresenceUpdate = async (message, clientKey, info) => {
|
|
303
|
+
this.presences.set(info.libraryId, info.userId, {
|
|
304
|
+
presence: message.presence,
|
|
305
|
+
replicaId: message.replicaId,
|
|
306
|
+
profile: await this.profiles.get(info.userId),
|
|
307
|
+
id: info.userId,
|
|
308
|
+
});
|
|
309
|
+
this.sender.broadcast(info.libraryId, {
|
|
310
|
+
type: 'presence-changed',
|
|
311
|
+
replicaId: message.replicaId,
|
|
312
|
+
userInfo: {
|
|
313
|
+
id: info.userId,
|
|
314
|
+
presence: message.presence,
|
|
315
|
+
profile: await this.profiles.get(info.userId),
|
|
316
|
+
replicaId: message.replicaId,
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
// mirror back to the replica which sent it so it has profile
|
|
320
|
+
[]);
|
|
321
|
+
};
|
|
322
|
+
this.onPresenceLost = async (libraryId, replicaId, userId) => {
|
|
323
|
+
this.log('User disconnected from all replicas:', userId);
|
|
324
|
+
this.sender.broadcast(libraryId, {
|
|
325
|
+
type: 'presence-offline',
|
|
326
|
+
replicaId,
|
|
327
|
+
userId,
|
|
328
|
+
});
|
|
329
|
+
if (Object.keys(this.presences.all(libraryId)).length === 0) {
|
|
330
|
+
this.log(`All users have disconnected from ${libraryId}`);
|
|
331
|
+
// could happen - if the server is shutting down manually
|
|
332
|
+
if (!this.db.open) {
|
|
333
|
+
this.log('debug', 'Database not open, skipping cleanup');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const pendingDelete = this.files.getPendingDeletes(libraryId);
|
|
337
|
+
if (pendingDelete.length > 0) {
|
|
338
|
+
if (!this.fileStorage) {
|
|
339
|
+
throw new Error('File storage not configured, cannot delete files');
|
|
340
|
+
}
|
|
341
|
+
this.log('Deleting files:', pendingDelete.map((f) => f.fileId).join(', '));
|
|
342
|
+
await Promise.all(pendingDelete.map(async (fileInfo) => {
|
|
343
|
+
var _a;
|
|
344
|
+
try {
|
|
345
|
+
await ((_a = this.fileStorage) === null || _a === void 0 ? void 0 : _a.delete({
|
|
346
|
+
libraryId: fileInfo.libraryId,
|
|
347
|
+
fileName: fileInfo.name,
|
|
348
|
+
id: fileInfo.fileId,
|
|
349
|
+
type: fileInfo.type,
|
|
350
|
+
}));
|
|
351
|
+
this.files.delete(libraryId, fileInfo.fileId);
|
|
352
|
+
}
|
|
353
|
+
catch (e) {
|
|
354
|
+
this.log('Failed to delete file', fileInfo.fileId, ' the file will remain in a pending delete state', e);
|
|
355
|
+
}
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
this.destroy = async (libraryId) => {
|
|
361
|
+
var _a;
|
|
362
|
+
this.presences.clear(libraryId);
|
|
363
|
+
this.replicas.deleteAll(libraryId);
|
|
364
|
+
this.operations.deleteAll(libraryId);
|
|
365
|
+
this.baselines.deleteAll(libraryId);
|
|
366
|
+
const allFiles = this.files.getAll(libraryId);
|
|
367
|
+
if (allFiles.length > 0) {
|
|
368
|
+
this.log(`Deleting ${allFiles.length} files for library ${libraryId}`, allFiles.map((f) => f.fileId).join(', '));
|
|
369
|
+
for (const fileInfo of allFiles) {
|
|
370
|
+
await ((_a = this.fileStorage) === null || _a === void 0 ? void 0 : _a.delete({
|
|
371
|
+
libraryId,
|
|
372
|
+
fileName: fileInfo.name,
|
|
373
|
+
id: fileInfo.fileId,
|
|
374
|
+
type: fileInfo.type,
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
this.files.deleteAll(libraryId);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
this.getPresence = (libraryId) => {
|
|
381
|
+
return this.presences.all(libraryId);
|
|
382
|
+
};
|
|
383
|
+
this.evictUser = (libraryId, userId) => {
|
|
384
|
+
this.replicas.deleteAllForUser(libraryId, userId);
|
|
385
|
+
};
|
|
386
|
+
this.getDocumentSnapshot = (libraryId, oid) => {
|
|
387
|
+
return this.hydrateObject(libraryId, oid);
|
|
388
|
+
};
|
|
389
|
+
this.getObjectSnapshot = (libraryId, oid) => {
|
|
390
|
+
var _a;
|
|
391
|
+
const baseline = this.baselines.get(libraryId, oid);
|
|
392
|
+
const ops = this.operations.getAllFor(libraryId, oid);
|
|
393
|
+
const snapshot = applyOperations((_a = baseline === null || baseline === void 0 ? void 0 : baseline.snapshot) !== null && _a !== void 0 ? _a : undefined, ops);
|
|
394
|
+
return snapshot;
|
|
395
|
+
};
|
|
396
|
+
this.hydrateObject = (libraryId, oid) => {
|
|
397
|
+
const snapshot = this.getObjectSnapshot(libraryId, oid);
|
|
398
|
+
if (Array.isArray(snapshot)) {
|
|
399
|
+
return snapshot.map((item, index) => {
|
|
400
|
+
if (isObjectRef(item)) {
|
|
401
|
+
return this.hydrateObject(libraryId, item.id);
|
|
402
|
+
}
|
|
403
|
+
else if (isFileRef(item)) {
|
|
404
|
+
return this.hydrateFile(libraryId, item.id);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
return item;
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
else if (snapshot && typeof snapshot === 'object') {
|
|
412
|
+
const hydrated = Object.assign({}, snapshot);
|
|
413
|
+
for (const [key, value] of Object.entries(snapshot)) {
|
|
414
|
+
if (isObjectRef(value)) {
|
|
415
|
+
hydrated[key] = this.hydrateObject(libraryId, value.id);
|
|
416
|
+
}
|
|
417
|
+
else if (isFileRef(value)) {
|
|
418
|
+
hydrated[key] = this.hydrateFile(libraryId, value.id);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return hydrated;
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
return snapshot;
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
this.hydrateFile = (libraryId, fileId) => {
|
|
428
|
+
var _a, _b;
|
|
429
|
+
const data = this.files.get(libraryId, fileId);
|
|
430
|
+
if (data) {
|
|
431
|
+
const url = (_b = (_a = this.fileStorage) === null || _a === void 0 ? void 0 : _a.getUrl({
|
|
432
|
+
fileName: data.name,
|
|
433
|
+
id: data.fileId,
|
|
434
|
+
libraryId,
|
|
435
|
+
type: data.type,
|
|
436
|
+
})) !== null && _b !== void 0 ? _b : null;
|
|
437
|
+
return {
|
|
438
|
+
url,
|
|
439
|
+
id: data.fileId,
|
|
440
|
+
name: data.name,
|
|
441
|
+
type: data.type,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
this.close = () => {
|
|
449
|
+
this.db.close();
|
|
450
|
+
};
|
|
451
|
+
this.db = db;
|
|
452
|
+
this.log = log;
|
|
453
|
+
this.disableRebasing = !!disableRebasing;
|
|
454
|
+
this.sender = sender;
|
|
455
|
+
this.profiles = profiles;
|
|
456
|
+
this.replicas = new ReplicaInfos(this.db, replicaTruancyMinutes);
|
|
457
|
+
this.operations = new OperationHistory(this.db);
|
|
458
|
+
this.baselines = new Baselines(this.db);
|
|
459
|
+
this.files = fileMetadata;
|
|
460
|
+
this.fileStorage = fileStorage;
|
|
461
|
+
this.presences.on('lost', this.onPresenceLost);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
464
|
//# sourceMappingURL=ServerLibrary.js.map
|