@verdant-web/server 1.7.8 → 1.7.10

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