@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.
Files changed (97) hide show
  1. package/dist/cjs/Baselines.d.ts +14 -14
  2. package/dist/cjs/Baselines.js +77 -77
  3. package/dist/cjs/ClientConnection.d.ts +18 -18
  4. package/dist/cjs/ClientConnection.js +105 -105
  5. package/dist/cjs/MessageSender.d.ts +5 -5
  6. package/dist/cjs/MessageSender.js +2 -2
  7. package/dist/cjs/OperationHistory.d.ts +34 -34
  8. package/dist/cjs/OperationHistory.js +108 -108
  9. package/dist/cjs/Presence.d.ts +15 -15
  10. package/dist/cjs/Presence.js +59 -59
  11. package/dist/cjs/Profiles.d.ts +10 -10
  12. package/dist/cjs/Profiles.js +19 -19
  13. package/dist/cjs/ReplicaKeepaliveTimers.d.ts +17 -17
  14. package/dist/cjs/ReplicaKeepaliveTimers.js +30 -30
  15. package/dist/cjs/Replicas.d.ts +23 -23
  16. package/dist/cjs/Replicas.js +132 -132
  17. package/dist/cjs/Server.d.ts +121 -121
  18. package/dist/cjs/Server.js +377 -377
  19. package/dist/cjs/ServerLibrary.d.ts +63 -63
  20. package/dist/cjs/ServerLibrary.js +467 -467
  21. package/dist/cjs/TokenProvider.d.ts +16 -16
  22. package/dist/cjs/TokenProvider.js +26 -26
  23. package/dist/cjs/TokenVerifier.d.ts +16 -16
  24. package/dist/cjs/TokenVerifier.js +28 -28
  25. package/dist/cjs/files/FileMetadata.d.ts +28 -28
  26. package/dist/cjs/files/FileMetadata.js +46 -46
  27. package/dist/cjs/files/FileStorage.d.ts +35 -35
  28. package/dist/cjs/files/FileStorage.js +71 -71
  29. package/dist/cjs/index.d.ts +9 -9
  30. package/dist/cjs/index.js +11 -11
  31. package/dist/cjs/migrations/v0.sql.d.ts +2 -2
  32. package/dist/cjs/migrations/v0.sql.js +3 -3
  33. package/dist/cjs/migrations/v1.sql.d.ts +2 -2
  34. package/dist/cjs/migrations/v1.sql.js +3 -3
  35. package/dist/cjs/migrations/v2.sql.d.ts +2 -2
  36. package/dist/cjs/migrations/v2.sql.js +4 -4
  37. package/dist/cjs/migrations/v3.sql.d.ts +2 -2
  38. package/dist/cjs/migrations/v3.sql.js +4 -4
  39. package/dist/cjs/migrations/v4.sql.d.ts +2 -2
  40. package/dist/cjs/migrations/v4.sql.js +4 -4
  41. package/dist/cjs/migrations.d.ts +2 -2
  42. package/dist/cjs/migrations.js +32 -32
  43. package/dist/cjs/types.d.ts +22 -22
  44. package/dist/cjs/types.js +2 -2
  45. package/dist/esm/Baselines.d.ts +14 -14
  46. package/dist/esm/Baselines.js +73 -73
  47. package/dist/esm/ClientConnection.d.ts +18 -18
  48. package/dist/esm/ClientConnection.js +101 -101
  49. package/dist/esm/MessageSender.d.ts +5 -5
  50. package/dist/esm/MessageSender.js +1 -1
  51. package/dist/esm/OperationHistory.d.ts +34 -34
  52. package/dist/esm/OperationHistory.js +104 -104
  53. package/dist/esm/Presence.d.ts +15 -15
  54. package/dist/esm/Presence.js +52 -52
  55. package/dist/esm/Profiles.d.ts +10 -10
  56. package/dist/esm/Profiles.js +15 -15
  57. package/dist/esm/ReplicaKeepaliveTimers.d.ts +17 -17
  58. package/dist/esm/ReplicaKeepaliveTimers.js +26 -26
  59. package/dist/esm/Replicas.d.ts +23 -23
  60. package/dist/esm/Replicas.js +128 -128
  61. package/dist/esm/Server.d.ts +121 -121
  62. package/dist/esm/Server.js +370 -370
  63. package/dist/esm/ServerLibrary.d.ts +63 -63
  64. package/dist/esm/ServerLibrary.js +463 -463
  65. package/dist/esm/TokenProvider.d.ts +16 -16
  66. package/dist/esm/TokenProvider.js +19 -19
  67. package/dist/esm/TokenVerifier.d.ts +16 -16
  68. package/dist/esm/TokenVerifier.js +21 -21
  69. package/dist/esm/files/FileMetadata.d.ts +28 -28
  70. package/dist/esm/files/FileMetadata.js +42 -42
  71. package/dist/esm/files/FileStorage.d.ts +35 -35
  72. package/dist/esm/files/FileStorage.js +44 -44
  73. package/dist/esm/index.d.ts +9 -9
  74. package/dist/esm/index.js +4 -4
  75. package/dist/esm/migrations/v0.sql.d.ts +2 -2
  76. package/dist/esm/migrations/v0.sql.js +1 -1
  77. package/dist/esm/migrations/v1.sql.d.ts +2 -2
  78. package/dist/esm/migrations/v1.sql.js +1 -1
  79. package/dist/esm/migrations/v2.sql.d.ts +2 -2
  80. package/dist/esm/migrations/v2.sql.js +2 -2
  81. package/dist/esm/migrations/v3.sql.d.ts +2 -2
  82. package/dist/esm/migrations/v3.sql.js +2 -2
  83. package/dist/esm/migrations/v4.sql.d.ts +2 -2
  84. package/dist/esm/migrations/v4.sql.js +2 -2
  85. package/dist/esm/migrations.d.ts +2 -2
  86. package/dist/esm/migrations.js +25 -25
  87. package/dist/esm/types.d.ts +22 -22
  88. package/dist/esm/types.js +1 -1
  89. package/dist/tsconfig-cjs.tsbuildinfo +1 -1
  90. package/dist/tsconfig.tsbuildinfo +1 -1
  91. package/package.json +2 -2
  92. package/dist/cjs/ServerStorage.d.ts +0 -35
  93. package/dist/cjs/ServerStorage.js +0 -60
  94. package/dist/cjs/ServerStorage.js.map +0 -1
  95. package/dist/esm/ServerStorage.d.ts +0 -44
  96. package/dist/esm/ServerStorage.js +0 -58
  97. 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