@verdant-web/server 2.1.2 → 3.0.0-next.0
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/esm/ClientConnection.js.map +1 -1
- package/dist/esm/Presence.js.map +1 -1
- package/dist/esm/Profiles.js.map +1 -1
- package/dist/esm/ReplicaKeepaliveTimers.js.map +1 -1
- package/dist/esm/Server.js +29 -30
- package/dist/esm/Server.js.map +1 -1
- package/dist/esm/ServerLibrary.js +161 -129
- package/dist/esm/ServerLibrary.js.map +1 -1
- package/dist/esm/ServerLibrary.test.js +369 -0
- package/dist/esm/ServerLibrary.test.js.map +1 -0
- package/dist/esm/TokenProvider.js.map +1 -1
- package/dist/esm/TokenVerifier.js.map +1 -1
- package/dist/esm/files/FileStorage.js.map +1 -1
- package/dist/esm/storage/Storage.js +2 -0
- package/dist/esm/storage/Storage.js.map +1 -0
- package/dist/esm/storage/index.js +3 -0
- package/dist/esm/storage/index.js.map +1 -0
- package/dist/esm/storage/sql/SqlBaselines.js +101 -0
- package/dist/esm/storage/sql/SqlBaselines.js.map +1 -0
- package/dist/esm/storage/sql/SqlFileMetadata.js +69 -0
- package/dist/esm/storage/sql/SqlFileMetadata.js.map +1 -0
- package/dist/esm/storage/sql/SqlOperations.js +104 -0
- package/dist/esm/storage/sql/SqlOperations.js.map +1 -0
- package/dist/esm/storage/sql/SqlReplicas.js +163 -0
- package/dist/esm/storage/sql/SqlReplicas.js.map +1 -0
- package/dist/esm/storage/sql/migrations/v1.js +59 -0
- package/dist/esm/storage/sql/migrations/v1.js.map +1 -0
- package/dist/esm/storage/sql/migrations.js +3 -0
- package/dist/esm/storage/sql/migrations.js.map +1 -0
- package/dist/esm/storage/sql/sqlStorage.js +43 -0
- package/dist/esm/storage/sql/sqlStorage.js.map +1 -0
- package/dist/esm/storage/sql/tables.js +2 -0
- package/dist/esm/storage/sql/tables.js.map +1 -0
- package/package.json +20 -14
- package/src/Server.ts +37 -43
- package/src/ServerLibrary.test.ts +447 -0
- package/src/ServerLibrary.ts +253 -166
- package/src/TokenProvider.ts +3 -0
- package/src/index.ts +1 -0
- package/src/storage/Storage.ts +118 -0
- package/src/storage/index.ts +2 -0
- package/src/storage/sql/SqlBaselines.ts +141 -0
- package/src/storage/sql/SqlFileMetadata.ts +94 -0
- package/src/storage/sql/SqlOperations.ts +149 -0
- package/src/storage/sql/SqlReplicas.ts +234 -0
- package/src/storage/sql/migrations/v1.ts +64 -0
- package/src/storage/sql/migrations.ts +3 -0
- package/src/storage/sql/sqlStorage.ts +59 -0
- package/src/storage/sql/tables.ts +45 -0
- package/src/types.ts +31 -4
- package/bin/server.mjs +0 -115
- package/dist/cjs/Baselines.d.ts +0 -16
- package/dist/cjs/Baselines.js +0 -128
- package/dist/cjs/Baselines.js.map +0 -1
- package/dist/cjs/ClientConnection.d.ts +0 -20
- package/dist/cjs/ClientConnection.js +0 -139
- package/dist/cjs/ClientConnection.js.map +0 -1
- package/dist/cjs/MessageSender.d.ts +0 -5
- package/dist/cjs/MessageSender.js +0 -3
- package/dist/cjs/MessageSender.js.map +0 -1
- package/dist/cjs/OperationHistory.d.ts +0 -36
- package/dist/cjs/OperationHistory.js +0 -165
- package/dist/cjs/OperationHistory.js.map +0 -1
- package/dist/cjs/Presence.d.ts +0 -15
- package/dist/cjs/Presence.js +0 -60
- package/dist/cjs/Presence.js.map +0 -1
- package/dist/cjs/Profiles.d.ts +0 -10
- package/dist/cjs/Profiles.js +0 -20
- package/dist/cjs/Profiles.js.map +0 -1
- package/dist/cjs/ReplicaKeepaliveTimers.d.ts +0 -17
- package/dist/cjs/ReplicaKeepaliveTimers.js +0 -31
- package/dist/cjs/ReplicaKeepaliveTimers.js.map +0 -1
- package/dist/cjs/Replicas.d.ts +0 -25
- package/dist/cjs/Replicas.js +0 -196
- package/dist/cjs/Replicas.js.map +0 -1
- package/dist/cjs/Server.d.ts +0 -180
- package/dist/cjs/Server.js +0 -552
- package/dist/cjs/Server.js.map +0 -1
- package/dist/cjs/ServerLibrary.d.ts +0 -77
- package/dist/cjs/ServerLibrary.js +0 -497
- package/dist/cjs/ServerLibrary.js.map +0 -1
- package/dist/cjs/TokenProvider.d.ts +0 -16
- package/dist/cjs/TokenProvider.js +0 -27
- package/dist/cjs/TokenProvider.js.map +0 -1
- package/dist/cjs/TokenVerifier.d.ts +0 -16
- package/dist/cjs/TokenVerifier.js +0 -40
- package/dist/cjs/TokenVerifier.js.map +0 -1
- package/dist/cjs/files/FileMetadata.d.ts +0 -28
- package/dist/cjs/files/FileMetadata.js +0 -69
- package/dist/cjs/files/FileMetadata.js.map +0 -1
- package/dist/cjs/files/FileStorage.d.ts +0 -35
- package/dist/cjs/files/FileStorage.js +0 -72
- package/dist/cjs/files/FileStorage.js.map +0 -1
- package/dist/cjs/index.d.ts +0 -9
- package/dist/cjs/index.js +0 -12
- package/dist/cjs/index.js.map +0 -1
- package/dist/cjs/migrations/v0.d.ts +0 -1
- package/dist/cjs/migrations/v0.js +0 -30
- package/dist/cjs/migrations/v0.js.map +0 -1
- package/dist/cjs/migrations/v1.d.ts +0 -1
- package/dist/cjs/migrations/v1.js +0 -32
- package/dist/cjs/migrations/v1.js.map +0 -1
- package/dist/cjs/migrations/v2.d.ts +0 -1
- package/dist/cjs/migrations/v2.js +0 -29
- package/dist/cjs/migrations/v2.js.map +0 -1
- package/dist/cjs/migrations/v3.d.ts +0 -1
- package/dist/cjs/migrations/v3.js +0 -15
- package/dist/cjs/migrations/v3.js.map +0 -1
- package/dist/cjs/migrations/v4.d.ts +0 -1
- package/dist/cjs/migrations/v4.js +0 -9
- package/dist/cjs/migrations/v4.js.map +0 -1
- package/dist/cjs/migrations/v5.d.ts +0 -2
- package/dist/cjs/migrations/v5.js +0 -37
- package/dist/cjs/migrations/v5.js.map +0 -1
- package/dist/cjs/migrations/v6.d.ts +0 -1
- package/dist/cjs/migrations/v6.js +0 -8
- package/dist/cjs/migrations/v6.js.map +0 -1
- package/dist/cjs/migrations/v7.d.ts +0 -1
- package/dist/cjs/migrations/v7.js +0 -10
- package/dist/cjs/migrations/v7.js.map +0 -1
- package/dist/cjs/migrations.d.ts +0 -6
- package/dist/cjs/migrations.js +0 -72
- package/dist/cjs/migrations.js.map +0 -1
- package/dist/cjs/migrations.test.d.ts +0 -1
- package/dist/cjs/migrations.test.js +0 -121
- package/dist/cjs/migrations.test.js.map +0 -1
- package/dist/cjs/types.d.ts +0 -23
- package/dist/cjs/types.js +0 -3
- package/dist/cjs/types.js.map +0 -1
- package/dist/esm/Baselines.d.ts +0 -16
- package/dist/esm/Baselines.js +0 -124
- package/dist/esm/Baselines.js.map +0 -1
- package/dist/esm/ClientConnection.d.ts +0 -20
- package/dist/esm/MessageSender.d.ts +0 -5
- package/dist/esm/OperationHistory.d.ts +0 -36
- package/dist/esm/OperationHistory.js +0 -161
- package/dist/esm/OperationHistory.js.map +0 -1
- package/dist/esm/Presence.d.ts +0 -15
- package/dist/esm/Profiles.d.ts +0 -10
- package/dist/esm/ReplicaKeepaliveTimers.d.ts +0 -17
- package/dist/esm/Replicas.d.ts +0 -25
- package/dist/esm/Replicas.js +0 -192
- package/dist/esm/Replicas.js.map +0 -1
- package/dist/esm/Server.d.ts +0 -180
- package/dist/esm/ServerLibrary.d.ts +0 -77
- package/dist/esm/TokenProvider.d.ts +0 -16
- package/dist/esm/TokenVerifier.d.ts +0 -16
- package/dist/esm/files/FileMetadata.d.ts +0 -28
- package/dist/esm/files/FileMetadata.js +0 -65
- package/dist/esm/files/FileMetadata.js.map +0 -1
- package/dist/esm/files/FileStorage.d.ts +0 -35
- package/dist/esm/index.d.ts +0 -9
- package/dist/esm/migrations/v0.d.ts +0 -1
- package/dist/esm/migrations/v0.js +0 -27
- package/dist/esm/migrations/v0.js.map +0 -1
- package/dist/esm/migrations/v1.d.ts +0 -1
- package/dist/esm/migrations/v1.js +0 -29
- package/dist/esm/migrations/v1.js.map +0 -1
- package/dist/esm/migrations/v2.d.ts +0 -1
- package/dist/esm/migrations/v2.js +0 -26
- package/dist/esm/migrations/v2.js.map +0 -1
- package/dist/esm/migrations/v3.d.ts +0 -1
- package/dist/esm/migrations/v3.js +0 -12
- package/dist/esm/migrations/v3.js.map +0 -1
- package/dist/esm/migrations/v4.d.ts +0 -1
- package/dist/esm/migrations/v4.js +0 -6
- package/dist/esm/migrations/v4.js.map +0 -1
- package/dist/esm/migrations/v5.d.ts +0 -2
- package/dist/esm/migrations/v5.js +0 -33
- package/dist/esm/migrations/v5.js.map +0 -1
- package/dist/esm/migrations/v6.d.ts +0 -1
- package/dist/esm/migrations/v6.js +0 -5
- package/dist/esm/migrations/v6.js.map +0 -1
- package/dist/esm/migrations/v7.d.ts +0 -1
- package/dist/esm/migrations/v7.js +0 -7
- package/dist/esm/migrations/v7.js.map +0 -1
- package/dist/esm/migrations.d.ts +0 -6
- package/dist/esm/migrations.js +0 -45
- package/dist/esm/migrations.js.map +0 -1
- package/dist/esm/migrations.test.d.ts +0 -1
- package/dist/esm/migrations.test.js +0 -116
- package/dist/esm/migrations.test.js.map +0 -1
- package/dist/esm/types.d.ts +0 -23
- package/dist/tsconfig-cjs.tsbuildinfo +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/Baselines.ts +0 -198
- package/src/OperationHistory.ts +0 -232
- package/src/Replicas.ts +0 -274
- package/src/files/FileMetadata.ts +0 -112
- package/src/migrations/v0.ts +0 -26
- package/src/migrations/v1.ts +0 -28
- package/src/migrations/v2.ts +0 -26
- package/src/migrations/v3.ts +0 -12
- package/src/migrations/v4.ts +0 -6
- package/src/migrations/v5.ts +0 -49
- package/src/migrations/v6.ts +0 -4
- package/src/migrations/v7.ts +0 -6
- package/src/migrations.test.ts +0 -133
- package/src/migrations.ts +0 -61
package/src/Server.ts
CHANGED
|
@@ -9,28 +9,21 @@ import {
|
|
|
9
9
|
ServerMessage,
|
|
10
10
|
VerdantError,
|
|
11
11
|
} from '@verdant-web/common';
|
|
12
|
+
import busboy from 'busboy';
|
|
12
13
|
import EventEmitter from 'events';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import create from 'better-sqlite3';
|
|
17
|
-
import { MessageSender } from './MessageSender.js';
|
|
14
|
+
import { Server as HttpServer, IncomingMessage, ServerResponse } from 'http';
|
|
15
|
+
import { TransformStream } from 'node:stream/web';
|
|
16
|
+
import internal, { Readable } from 'stream';
|
|
18
17
|
import { URL } from 'url';
|
|
18
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
19
19
|
import { ClientConnectionManager } from './ClientConnection.js';
|
|
20
|
-
import { ReplicaKeepaliveTimers } from './ReplicaKeepaliveTimers.js';
|
|
21
|
-
import { TokenInfo, TokenVerifier } from './TokenVerifier.js';
|
|
22
|
-
import busboy from 'busboy';
|
|
23
20
|
import { FileInfo, FileStorage } from './files/FileStorage.js';
|
|
24
|
-
import
|
|
25
|
-
import {
|
|
21
|
+
import { MessageSender } from './MessageSender.js';
|
|
22
|
+
import { UserProfileLoader, UserProfiles } from './Profiles.js';
|
|
23
|
+
import { ReplicaKeepaliveTimers } from './ReplicaKeepaliveTimers.js';
|
|
26
24
|
import { ServerLibrary } from './ServerLibrary.js';
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
ReadableStream,
|
|
30
|
-
ReadableWritablePair,
|
|
31
|
-
TransformStream,
|
|
32
|
-
WritableStream,
|
|
33
|
-
} from 'node:stream/web';
|
|
25
|
+
import { TokenInfo, TokenVerifier } from './TokenVerifier.js';
|
|
26
|
+
import { Storage, StorageFactory } from './storage/Storage.js';
|
|
34
27
|
|
|
35
28
|
export interface ServerOptions {
|
|
36
29
|
/**
|
|
@@ -45,9 +38,10 @@ export interface ServerOptions {
|
|
|
45
38
|
*/
|
|
46
39
|
tokenSecret: string;
|
|
47
40
|
/**
|
|
48
|
-
*
|
|
41
|
+
* Your storage implementation. See the sqlStorage export for a built-in
|
|
42
|
+
* option.
|
|
49
43
|
*/
|
|
50
|
-
|
|
44
|
+
storage: StorageFactory;
|
|
51
45
|
/**
|
|
52
46
|
* Optionally provide an implementation of the UserProfiles interface to look up
|
|
53
47
|
* static user profile data to use in persistence. This data is considered trusted
|
|
@@ -76,7 +70,7 @@ export interface ServerOptions {
|
|
|
76
70
|
* of this library.
|
|
77
71
|
*/
|
|
78
72
|
fileStorage?: FileStorage;
|
|
79
|
-
fileConfig?:
|
|
73
|
+
fileConfig?: { deleteExpirationDays?: number };
|
|
80
74
|
}
|
|
81
75
|
|
|
82
76
|
class DefaultProfiles implements UserProfiles<{ id: string }> {
|
|
@@ -117,7 +111,7 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
117
111
|
private httpServer!: HttpServer;
|
|
118
112
|
private wss: WebSocketServer;
|
|
119
113
|
private fileStorage?: FileStorage;
|
|
120
|
-
private
|
|
114
|
+
private storage: Storage;
|
|
121
115
|
private library;
|
|
122
116
|
|
|
123
117
|
private clientConnections = new ClientConnectionManager();
|
|
@@ -144,23 +138,20 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
144
138
|
});
|
|
145
139
|
|
|
146
140
|
this.fileStorage = options.fileStorage;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
this.fileMetadata = new FileMetadata(db, options.fileConfig);
|
|
141
|
+
this.storage = options.storage({
|
|
142
|
+
fileDeleteExpirationDays: options.fileConfig?.deleteExpirationDays ?? 14,
|
|
143
|
+
replicaTruancyMinutes: options.replicaTruancyMinutes || 60 * 24 * 14,
|
|
144
|
+
});
|
|
152
145
|
|
|
153
146
|
this.library = new ServerLibrary({
|
|
154
|
-
|
|
147
|
+
storage: this.storage,
|
|
155
148
|
sender: this,
|
|
156
149
|
profiles: new UserProfileLoader(
|
|
157
150
|
options.profiles || new DefaultProfiles(),
|
|
158
151
|
),
|
|
159
152
|
log: this.log,
|
|
160
153
|
disableRebasing: options.disableRebasing,
|
|
161
|
-
fileMetadata: this.fileMetadata,
|
|
162
154
|
fileStorage: this.fileStorage,
|
|
163
|
-
replicaTruancyMinutes: options.replicaTruancyMinutes || 60 * 24 * 14,
|
|
164
155
|
});
|
|
165
156
|
|
|
166
157
|
this.library.subscribe(
|
|
@@ -396,7 +387,7 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
396
387
|
|
|
397
388
|
private writeErrorResponse(e: unknown, res: ServerResponse) {
|
|
398
389
|
this.emit('error', e);
|
|
399
|
-
this.log('Error handling request', e);
|
|
390
|
+
this.log('error', 'Error handling request', e);
|
|
400
391
|
|
|
401
392
|
if (e instanceof VerdantError) {
|
|
402
393
|
// write error data to response
|
|
@@ -416,7 +407,7 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
416
407
|
// for fetch-style
|
|
417
408
|
private getErrorResponse(e: unknown) {
|
|
418
409
|
this.emit('error', e);
|
|
419
|
-
this.log('Error handling request', e);
|
|
410
|
+
this.log('error', 'Error handling request', e);
|
|
420
411
|
|
|
421
412
|
if (e instanceof VerdantError) {
|
|
422
413
|
return new Response(JSON.stringify(e.toResponse()), {
|
|
@@ -467,7 +458,7 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
467
458
|
headers: req.headers,
|
|
468
459
|
id,
|
|
469
460
|
});
|
|
470
|
-
this.log('File upload complete');
|
|
461
|
+
this.log('info', 'File upload complete', id);
|
|
471
462
|
res.writeHead(200);
|
|
472
463
|
res.write(JSON.stringify({ success: true }));
|
|
473
464
|
res.end();
|
|
@@ -525,7 +516,7 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
525
516
|
headers: headersAsRecord,
|
|
526
517
|
id,
|
|
527
518
|
});
|
|
528
|
-
this.log('File upload complete');
|
|
519
|
+
this.log('info', 'File upload complete', id);
|
|
529
520
|
return new Response(JSON.stringify({ success: true }), {
|
|
530
521
|
status: 200,
|
|
531
522
|
headers: {
|
|
@@ -571,7 +562,7 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
571
562
|
return new Promise((resolve, reject) => {
|
|
572
563
|
const bb = busboy({ headers });
|
|
573
564
|
|
|
574
|
-
bb.on('file', (fieldName, stream, fileInfo) => {
|
|
565
|
+
bb.on('file', async (fieldName, stream, fileInfo) => {
|
|
575
566
|
// too many 'info's....
|
|
576
567
|
const lofiFileInfo: FileInfo = {
|
|
577
568
|
id,
|
|
@@ -581,7 +572,8 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
581
572
|
};
|
|
582
573
|
// write metadata to storage
|
|
583
574
|
try {
|
|
584
|
-
this.
|
|
575
|
+
await this.storage.ready;
|
|
576
|
+
await this.storage.fileMetadata.put(info.libraryId, lofiFileInfo);
|
|
585
577
|
fs.put(stream, lofiFileInfo);
|
|
586
578
|
} catch (e) {
|
|
587
579
|
reject(e);
|
|
@@ -620,7 +612,8 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
620
612
|
): Promise<FileData> => {
|
|
621
613
|
const fs = this.getFileStorageOrThrow();
|
|
622
614
|
|
|
623
|
-
|
|
615
|
+
await this.storage.ready;
|
|
616
|
+
const fileInfo = await this.storage.fileMetadata.get(info.libraryId, id);
|
|
624
617
|
if (!fileInfo) {
|
|
625
618
|
throw new VerdantError(
|
|
626
619
|
VerdantError.Code.NotFound,
|
|
@@ -663,6 +656,7 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
663
656
|
message: ClientMessage,
|
|
664
657
|
) => {
|
|
665
658
|
this.log(
|
|
659
|
+
'debug',
|
|
666
660
|
'Got message from user',
|
|
667
661
|
info.userId,
|
|
668
662
|
', library',
|
|
@@ -715,26 +709,26 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
715
709
|
await Promise.all([
|
|
716
710
|
new Promise<void>((resolve) => {
|
|
717
711
|
setTimeout(() => {
|
|
718
|
-
|
|
712
|
+
this.log('warn', 'HTTP server close timed out');
|
|
719
713
|
resolve();
|
|
720
714
|
}, 5 * 1000);
|
|
721
715
|
this.httpServer.close(() => {
|
|
722
716
|
resolve();
|
|
723
|
-
this.log('HTTP server closed');
|
|
717
|
+
this.log('info', 'HTTP server closed');
|
|
724
718
|
});
|
|
725
719
|
}),
|
|
726
720
|
new Promise<void>((resolve) => {
|
|
727
721
|
setTimeout(() => {
|
|
728
|
-
|
|
722
|
+
this.log('warn', 'Socket server close timed out');
|
|
729
723
|
resolve();
|
|
730
724
|
}, 5 * 1000);
|
|
731
725
|
this.wss.close(() => {
|
|
732
726
|
resolve();
|
|
733
|
-
this.log('Socket server closed');
|
|
727
|
+
this.log('info', 'Socket server closed');
|
|
734
728
|
});
|
|
735
729
|
}),
|
|
736
730
|
]);
|
|
737
|
-
this.
|
|
731
|
+
await this.storage.close();
|
|
738
732
|
};
|
|
739
733
|
|
|
740
734
|
/**
|
|
@@ -749,7 +743,7 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
749
743
|
// disconnect all clients
|
|
750
744
|
this.clientConnections.disconnectAll(libraryId);
|
|
751
745
|
this.library.destroy(libraryId);
|
|
752
|
-
this.log('Evicted library', libraryId);
|
|
746
|
+
this.log('info', 'Evicted library', libraryId);
|
|
753
747
|
};
|
|
754
748
|
|
|
755
749
|
/**
|
|
@@ -775,7 +769,7 @@ export class Server extends EventEmitter implements MessageSender {
|
|
|
775
769
|
*/
|
|
776
770
|
evictUser = (libraryId: string, userId: string) => {
|
|
777
771
|
this.library.evictUser(libraryId, userId);
|
|
778
|
-
this.log('Evicted user', userId, 'from library', libraryId);
|
|
772
|
+
this.log('info', 'Evicted user', userId, 'from library', libraryId);
|
|
779
773
|
};
|
|
780
774
|
|
|
781
775
|
/**
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { UserProfileLoader } from './Profiles.js';
|
|
3
|
+
import { sqlStorage } from './storage/index.js';
|
|
4
|
+
import { ServerLibrary } from './ServerLibrary.js';
|
|
5
|
+
import {
|
|
6
|
+
ClientMessage,
|
|
7
|
+
DocumentBaseline,
|
|
8
|
+
HybridLogicalClockTimestampProvider,
|
|
9
|
+
Operation,
|
|
10
|
+
ReplicaType,
|
|
11
|
+
ServerMessage,
|
|
12
|
+
} from '@verdant-web/common';
|
|
13
|
+
import { TokenInfo } from './TokenVerifier.js';
|
|
14
|
+
import { MessageSender } from './MessageSender.js';
|
|
15
|
+
|
|
16
|
+
const profiles = new UserProfileLoader<{ id: string; name: string }>({
|
|
17
|
+
get: async (userId: string) => {
|
|
18
|
+
return {
|
|
19
|
+
id: userId,
|
|
20
|
+
name: 'Alice',
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const baseOptions = () => ({
|
|
26
|
+
profiles,
|
|
27
|
+
storage: sqlStorage({
|
|
28
|
+
databaseFile: ':memory:',
|
|
29
|
+
})({
|
|
30
|
+
fileDeleteExpirationDays: 7,
|
|
31
|
+
replicaTruancyMinutes: 60 * 24,
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
let time = 0;
|
|
36
|
+
const now = () => `faketime-${time++}`;
|
|
37
|
+
|
|
38
|
+
type TestReplica = {
|
|
39
|
+
key: string;
|
|
40
|
+
replicaId: string;
|
|
41
|
+
messages: ServerMessage[];
|
|
42
|
+
tokenInfo: TokenInfo;
|
|
43
|
+
};
|
|
44
|
+
function createTestReplica(id: string): TestReplica {
|
|
45
|
+
return {
|
|
46
|
+
key: `clientKey-${id}`,
|
|
47
|
+
replicaId: `replica-${id}`,
|
|
48
|
+
messages: [],
|
|
49
|
+
tokenInfo: {
|
|
50
|
+
libraryId: 'library-1',
|
|
51
|
+
syncEndpoint: 'http://localhost:3000/sync',
|
|
52
|
+
token: 'fake',
|
|
53
|
+
type: ReplicaType.Realtime,
|
|
54
|
+
userId: `user-${id}`,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// collects all received operations for a replica
|
|
60
|
+
function getAllReceivedReplicaOperations(replica: TestReplica) {
|
|
61
|
+
const operations: Operation[] = [];
|
|
62
|
+
for (const message of replica.messages) {
|
|
63
|
+
switch (message.type) {
|
|
64
|
+
case 'op-re':
|
|
65
|
+
case 'sync-resp':
|
|
66
|
+
operations.push(...message.operations);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return operations;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('ServerLibrary', () => {
|
|
74
|
+
describe('sync handling', () => {
|
|
75
|
+
it('should initialize a new library from a replica', async () => {
|
|
76
|
+
const sender = {
|
|
77
|
+
broadcast: vi.fn(),
|
|
78
|
+
send: vi.fn(),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const tokenInfo: TokenInfo = {
|
|
82
|
+
libraryId: 'library-1',
|
|
83
|
+
syncEndpoint: 'http://localhost:3000/sync',
|
|
84
|
+
token: 'fake',
|
|
85
|
+
type: ReplicaType.Realtime,
|
|
86
|
+
userId: 'user-1',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const library = new ServerLibrary({
|
|
90
|
+
...baseOptions(),
|
|
91
|
+
sender,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const messages: ClientMessage[] = [
|
|
95
|
+
{
|
|
96
|
+
type: 'sync',
|
|
97
|
+
baselines: [
|
|
98
|
+
{
|
|
99
|
+
oid: 'notes/1',
|
|
100
|
+
timestamp: now(),
|
|
101
|
+
snapshot: {
|
|
102
|
+
title: 'My Note',
|
|
103
|
+
content: 'This is my note.',
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
operations: [
|
|
108
|
+
{
|
|
109
|
+
oid: 'notes/1',
|
|
110
|
+
timestamp: now(),
|
|
111
|
+
data: {
|
|
112
|
+
op: 'set',
|
|
113
|
+
name: 'title',
|
|
114
|
+
value: 'My Note!',
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
oid: 'notes/2',
|
|
119
|
+
timestamp: now(),
|
|
120
|
+
data: {
|
|
121
|
+
op: 'initialize',
|
|
122
|
+
value: {
|
|
123
|
+
title: 'Another Note',
|
|
124
|
+
content: 'This is another note.',
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
replicaId: 'replica-1',
|
|
130
|
+
schemaVersion: 1,
|
|
131
|
+
since: null,
|
|
132
|
+
timestamp: now(),
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
// purposefully do not await in order - these all come at the same time
|
|
137
|
+
await Promise.all(
|
|
138
|
+
messages.map((m) => library.receive(m, 'clientKey-1', tokenInfo)),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// expect sender.broadcast for the operations, and a reply with sync response
|
|
142
|
+
expect(sender.broadcast).toHaveBeenCalledWith(
|
|
143
|
+
'library-1',
|
|
144
|
+
{
|
|
145
|
+
type: 'op-re',
|
|
146
|
+
operations: [
|
|
147
|
+
{
|
|
148
|
+
oid: 'notes/1',
|
|
149
|
+
timestamp: `faketime-1`,
|
|
150
|
+
data: {
|
|
151
|
+
op: 'set',
|
|
152
|
+
name: 'title',
|
|
153
|
+
value: 'My Note!',
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
oid: 'notes/2',
|
|
158
|
+
timestamp: `faketime-2`,
|
|
159
|
+
data: {
|
|
160
|
+
op: 'initialize',
|
|
161
|
+
value: {
|
|
162
|
+
title: 'Another Note',
|
|
163
|
+
content: 'This is another note.',
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
replicaId: 'replica-1',
|
|
169
|
+
globalAckTimestamp: undefined,
|
|
170
|
+
},
|
|
171
|
+
['clientKey-1'],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
expect(sender.send).toHaveBeenCalledWith('library-1', 'clientKey-1', {
|
|
175
|
+
type: 'sync-resp',
|
|
176
|
+
operations: [],
|
|
177
|
+
baselines: [],
|
|
178
|
+
ackThisNonce: undefined,
|
|
179
|
+
ackedTimestamp: 'faketime-3',
|
|
180
|
+
globalAckTimestamp: undefined,
|
|
181
|
+
overwriteLocalData: false,
|
|
182
|
+
peerPresence: {},
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should sync up to latest data, while also rebroadcasting concurrent new operations', async () => {
|
|
187
|
+
// this test setup is more involved - a fake sender will distribute messages to our
|
|
188
|
+
// fake replicas, and we will verify that the replicas end up with the right data.
|
|
189
|
+
const replicaA = createTestReplica('a');
|
|
190
|
+
const replicaB = createTestReplica('b');
|
|
191
|
+
const replicas = [replicaA, replicaB];
|
|
192
|
+
|
|
193
|
+
const sender: MessageSender = {
|
|
194
|
+
broadcast: (
|
|
195
|
+
libraryId: string,
|
|
196
|
+
message: ServerMessage,
|
|
197
|
+
omitKeys?: string[],
|
|
198
|
+
) => {
|
|
199
|
+
replicas.forEach((replica) => {
|
|
200
|
+
if (!omitKeys?.includes(replica.key)) {
|
|
201
|
+
replica.messages.push(message);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
send: (
|
|
206
|
+
libraryId: string,
|
|
207
|
+
clientKey: string,
|
|
208
|
+
message: ServerMessage,
|
|
209
|
+
) => {
|
|
210
|
+
const replica = replicas.find((r) => r.key === clientKey);
|
|
211
|
+
if (replica) {
|
|
212
|
+
replica.messages.push(message);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const library = new ServerLibrary({
|
|
218
|
+
...baseOptions(),
|
|
219
|
+
sender,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// replica A sends a sync message to initialize library
|
|
223
|
+
const syncMessageA: ClientMessage = {
|
|
224
|
+
type: 'sync',
|
|
225
|
+
baselines: [
|
|
226
|
+
{
|
|
227
|
+
oid: 'notes/1',
|
|
228
|
+
timestamp: now(),
|
|
229
|
+
snapshot: {
|
|
230
|
+
title: 'My Note',
|
|
231
|
+
content: 'This is my note.',
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
operations: [
|
|
236
|
+
{
|
|
237
|
+
oid: 'notes/1',
|
|
238
|
+
timestamp: now(),
|
|
239
|
+
data: {
|
|
240
|
+
op: 'set',
|
|
241
|
+
name: 'title',
|
|
242
|
+
value: 'My Note!',
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
replicaId: 'replica-a',
|
|
247
|
+
schemaVersion: 1,
|
|
248
|
+
since: null,
|
|
249
|
+
timestamp: now(),
|
|
250
|
+
};
|
|
251
|
+
await library.receive(syncMessageA, replicaA.key, replicaA.tokenInfo);
|
|
252
|
+
|
|
253
|
+
// concurrently, replica B syncs, and A sends more operations
|
|
254
|
+
const syncMessageB: ClientMessage = {
|
|
255
|
+
type: 'sync',
|
|
256
|
+
baselines: [],
|
|
257
|
+
operations: [],
|
|
258
|
+
replicaId: 'replica-b',
|
|
259
|
+
schemaVersion: 1,
|
|
260
|
+
since: null,
|
|
261
|
+
timestamp: now(),
|
|
262
|
+
resyncAll: true,
|
|
263
|
+
};
|
|
264
|
+
const opMessageA: ClientMessage = {
|
|
265
|
+
type: 'op',
|
|
266
|
+
replicaId: 'replica-a',
|
|
267
|
+
operations: [
|
|
268
|
+
{
|
|
269
|
+
oid: 'notes/1',
|
|
270
|
+
timestamp: now(),
|
|
271
|
+
data: {
|
|
272
|
+
op: 'set',
|
|
273
|
+
name: 'content',
|
|
274
|
+
value: 'This is my note!',
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
timestamp: now(),
|
|
279
|
+
};
|
|
280
|
+
await Promise.all([
|
|
281
|
+
library.receive(syncMessageB, replicaB.key, replicaB.tokenInfo),
|
|
282
|
+
library.receive(opMessageA, replicaA.key, replicaA.tokenInfo),
|
|
283
|
+
]);
|
|
284
|
+
|
|
285
|
+
// there are two valid scenario outcomes here:
|
|
286
|
+
// 1. A's operation is resolved before B's sync reads operations, and so it's
|
|
287
|
+
// included in B's sync response
|
|
288
|
+
// 2. B's sync reads operations before A's operation is resolved, and so it's
|
|
289
|
+
// not included in the sync, but is rebroadcast to B later
|
|
290
|
+
//
|
|
291
|
+
// Either way we should end up with that operation on B.
|
|
292
|
+
const operationsSentToB = getAllReceivedReplicaOperations(replicaB);
|
|
293
|
+
expect(operationsSentToB).toContainEqual({
|
|
294
|
+
oid: 'notes/1',
|
|
295
|
+
timestamp: 'faketime-5',
|
|
296
|
+
data: {
|
|
297
|
+
op: 'set',
|
|
298
|
+
name: 'title',
|
|
299
|
+
value: 'My Note!',
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
expect(operationsSentToB).toContainEqual({
|
|
303
|
+
oid: 'notes/1',
|
|
304
|
+
timestamp: 'faketime-8',
|
|
305
|
+
data: {
|
|
306
|
+
op: 'set',
|
|
307
|
+
name: 'content',
|
|
308
|
+
value: 'This is my note!',
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should reject syncs with data from read replicas', async () => {
|
|
314
|
+
const sender = {
|
|
315
|
+
broadcast: vi.fn(),
|
|
316
|
+
send: vi.fn(),
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const tokenInfo: TokenInfo = {
|
|
320
|
+
libraryId: 'library-1',
|
|
321
|
+
syncEndpoint: 'http://localhost:3000/sync',
|
|
322
|
+
token: 'fake',
|
|
323
|
+
type: ReplicaType.ReadOnlyPull,
|
|
324
|
+
userId: 'user-1',
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const library = new ServerLibrary({
|
|
328
|
+
...baseOptions(),
|
|
329
|
+
sender,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const messages: ClientMessage[] = [
|
|
333
|
+
{
|
|
334
|
+
type: 'sync',
|
|
335
|
+
baselines: [
|
|
336
|
+
{
|
|
337
|
+
oid: 'notes/1',
|
|
338
|
+
timestamp: now(),
|
|
339
|
+
snapshot: {
|
|
340
|
+
title: 'My Note',
|
|
341
|
+
content: 'This is my note.',
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
],
|
|
345
|
+
operations: [
|
|
346
|
+
{
|
|
347
|
+
oid: 'notes/1',
|
|
348
|
+
timestamp: now(),
|
|
349
|
+
data: {
|
|
350
|
+
op: 'set',
|
|
351
|
+
name: 'title',
|
|
352
|
+
value: 'My Note!',
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
oid: 'notes/2',
|
|
357
|
+
timestamp: now(),
|
|
358
|
+
data: {
|
|
359
|
+
op: 'initialize',
|
|
360
|
+
value: {
|
|
361
|
+
title: 'Another Note',
|
|
362
|
+
content: 'This is another note.',
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
replicaId: 'replica-1',
|
|
368
|
+
schemaVersion: 1,
|
|
369
|
+
since: null,
|
|
370
|
+
timestamp: now(),
|
|
371
|
+
},
|
|
372
|
+
];
|
|
373
|
+
|
|
374
|
+
// purposefully do not await in order - these all come at the same time
|
|
375
|
+
await Promise.all(
|
|
376
|
+
messages.map((m) => library.receive(m, 'clientKey-1', tokenInfo)),
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
expect(sender.send).toHaveBeenCalledOnce();
|
|
380
|
+
expect(sender.send).toHaveBeenCalledWith('library-1', 'clientKey-1', {
|
|
381
|
+
type: 'forbidden',
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('should reject messages from a replica claimed by a user other than the one recorded on the server', async () => {
|
|
386
|
+
const sender = {
|
|
387
|
+
broadcast: vi.fn(),
|
|
388
|
+
send: vi.fn(),
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const tokenInfo: TokenInfo = {
|
|
392
|
+
libraryId: 'library-1',
|
|
393
|
+
syncEndpoint: 'http://localhost:3000/sync',
|
|
394
|
+
token: 'fake',
|
|
395
|
+
type: ReplicaType.Realtime,
|
|
396
|
+
userId: 'user-1',
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const library = new ServerLibrary({
|
|
400
|
+
...baseOptions(),
|
|
401
|
+
sender,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// purposefully do not await in order - these all come at the same time
|
|
405
|
+
await library.receive(
|
|
406
|
+
{
|
|
407
|
+
type: 'sync',
|
|
408
|
+
baselines: [],
|
|
409
|
+
operations: [],
|
|
410
|
+
replicaId: 'replica-1',
|
|
411
|
+
schemaVersion: 1,
|
|
412
|
+
since: null,
|
|
413
|
+
timestamp: now(),
|
|
414
|
+
},
|
|
415
|
+
'clientKey-1',
|
|
416
|
+
tokenInfo,
|
|
417
|
+
);
|
|
418
|
+
sender.send.mockReset();
|
|
419
|
+
|
|
420
|
+
// now try to send a message from a different user with the same replica
|
|
421
|
+
const tokenInfo2: TokenInfo = {
|
|
422
|
+
libraryId: 'library-1',
|
|
423
|
+
syncEndpoint: 'http://localhost:3000/sync',
|
|
424
|
+
token: 'fake',
|
|
425
|
+
type: ReplicaType.Realtime,
|
|
426
|
+
userId: 'user-2',
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const message: ClientMessage = {
|
|
430
|
+
type: 'sync',
|
|
431
|
+
baselines: [],
|
|
432
|
+
operations: [],
|
|
433
|
+
replicaId: 'replica-1',
|
|
434
|
+
schemaVersion: 1,
|
|
435
|
+
since: null,
|
|
436
|
+
timestamp: now(),
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
await library.receive(message, 'clientKey-1', tokenInfo2);
|
|
440
|
+
|
|
441
|
+
expect(sender.send).toHaveBeenCalledOnce();
|
|
442
|
+
expect(sender.send).toHaveBeenCalledWith('library-1', 'clientKey-1', {
|
|
443
|
+
type: 'forbidden',
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
});
|