@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.
Files changed (199) hide show
  1. package/dist/esm/ClientConnection.js.map +1 -1
  2. package/dist/esm/Presence.js.map +1 -1
  3. package/dist/esm/Profiles.js.map +1 -1
  4. package/dist/esm/ReplicaKeepaliveTimers.js.map +1 -1
  5. package/dist/esm/Server.js +29 -30
  6. package/dist/esm/Server.js.map +1 -1
  7. package/dist/esm/ServerLibrary.js +161 -129
  8. package/dist/esm/ServerLibrary.js.map +1 -1
  9. package/dist/esm/ServerLibrary.test.js +369 -0
  10. package/dist/esm/ServerLibrary.test.js.map +1 -0
  11. package/dist/esm/TokenProvider.js.map +1 -1
  12. package/dist/esm/TokenVerifier.js.map +1 -1
  13. package/dist/esm/files/FileStorage.js.map +1 -1
  14. package/dist/esm/storage/Storage.js +2 -0
  15. package/dist/esm/storage/Storage.js.map +1 -0
  16. package/dist/esm/storage/index.js +3 -0
  17. package/dist/esm/storage/index.js.map +1 -0
  18. package/dist/esm/storage/sql/SqlBaselines.js +101 -0
  19. package/dist/esm/storage/sql/SqlBaselines.js.map +1 -0
  20. package/dist/esm/storage/sql/SqlFileMetadata.js +69 -0
  21. package/dist/esm/storage/sql/SqlFileMetadata.js.map +1 -0
  22. package/dist/esm/storage/sql/SqlOperations.js +104 -0
  23. package/dist/esm/storage/sql/SqlOperations.js.map +1 -0
  24. package/dist/esm/storage/sql/SqlReplicas.js +163 -0
  25. package/dist/esm/storage/sql/SqlReplicas.js.map +1 -0
  26. package/dist/esm/storage/sql/migrations/v1.js +59 -0
  27. package/dist/esm/storage/sql/migrations/v1.js.map +1 -0
  28. package/dist/esm/storage/sql/migrations.js +3 -0
  29. package/dist/esm/storage/sql/migrations.js.map +1 -0
  30. package/dist/esm/storage/sql/sqlStorage.js +43 -0
  31. package/dist/esm/storage/sql/sqlStorage.js.map +1 -0
  32. package/dist/esm/storage/sql/tables.js +2 -0
  33. package/dist/esm/storage/sql/tables.js.map +1 -0
  34. package/package.json +20 -14
  35. package/src/Server.ts +37 -43
  36. package/src/ServerLibrary.test.ts +447 -0
  37. package/src/ServerLibrary.ts +253 -166
  38. package/src/TokenProvider.ts +3 -0
  39. package/src/index.ts +1 -0
  40. package/src/storage/Storage.ts +118 -0
  41. package/src/storage/index.ts +2 -0
  42. package/src/storage/sql/SqlBaselines.ts +141 -0
  43. package/src/storage/sql/SqlFileMetadata.ts +94 -0
  44. package/src/storage/sql/SqlOperations.ts +149 -0
  45. package/src/storage/sql/SqlReplicas.ts +234 -0
  46. package/src/storage/sql/migrations/v1.ts +64 -0
  47. package/src/storage/sql/migrations.ts +3 -0
  48. package/src/storage/sql/sqlStorage.ts +59 -0
  49. package/src/storage/sql/tables.ts +45 -0
  50. package/src/types.ts +31 -4
  51. package/bin/server.mjs +0 -115
  52. package/dist/cjs/Baselines.d.ts +0 -16
  53. package/dist/cjs/Baselines.js +0 -128
  54. package/dist/cjs/Baselines.js.map +0 -1
  55. package/dist/cjs/ClientConnection.d.ts +0 -20
  56. package/dist/cjs/ClientConnection.js +0 -139
  57. package/dist/cjs/ClientConnection.js.map +0 -1
  58. package/dist/cjs/MessageSender.d.ts +0 -5
  59. package/dist/cjs/MessageSender.js +0 -3
  60. package/dist/cjs/MessageSender.js.map +0 -1
  61. package/dist/cjs/OperationHistory.d.ts +0 -36
  62. package/dist/cjs/OperationHistory.js +0 -165
  63. package/dist/cjs/OperationHistory.js.map +0 -1
  64. package/dist/cjs/Presence.d.ts +0 -15
  65. package/dist/cjs/Presence.js +0 -60
  66. package/dist/cjs/Presence.js.map +0 -1
  67. package/dist/cjs/Profiles.d.ts +0 -10
  68. package/dist/cjs/Profiles.js +0 -20
  69. package/dist/cjs/Profiles.js.map +0 -1
  70. package/dist/cjs/ReplicaKeepaliveTimers.d.ts +0 -17
  71. package/dist/cjs/ReplicaKeepaliveTimers.js +0 -31
  72. package/dist/cjs/ReplicaKeepaliveTimers.js.map +0 -1
  73. package/dist/cjs/Replicas.d.ts +0 -25
  74. package/dist/cjs/Replicas.js +0 -196
  75. package/dist/cjs/Replicas.js.map +0 -1
  76. package/dist/cjs/Server.d.ts +0 -180
  77. package/dist/cjs/Server.js +0 -552
  78. package/dist/cjs/Server.js.map +0 -1
  79. package/dist/cjs/ServerLibrary.d.ts +0 -77
  80. package/dist/cjs/ServerLibrary.js +0 -497
  81. package/dist/cjs/ServerLibrary.js.map +0 -1
  82. package/dist/cjs/TokenProvider.d.ts +0 -16
  83. package/dist/cjs/TokenProvider.js +0 -27
  84. package/dist/cjs/TokenProvider.js.map +0 -1
  85. package/dist/cjs/TokenVerifier.d.ts +0 -16
  86. package/dist/cjs/TokenVerifier.js +0 -40
  87. package/dist/cjs/TokenVerifier.js.map +0 -1
  88. package/dist/cjs/files/FileMetadata.d.ts +0 -28
  89. package/dist/cjs/files/FileMetadata.js +0 -69
  90. package/dist/cjs/files/FileMetadata.js.map +0 -1
  91. package/dist/cjs/files/FileStorage.d.ts +0 -35
  92. package/dist/cjs/files/FileStorage.js +0 -72
  93. package/dist/cjs/files/FileStorage.js.map +0 -1
  94. package/dist/cjs/index.d.ts +0 -9
  95. package/dist/cjs/index.js +0 -12
  96. package/dist/cjs/index.js.map +0 -1
  97. package/dist/cjs/migrations/v0.d.ts +0 -1
  98. package/dist/cjs/migrations/v0.js +0 -30
  99. package/dist/cjs/migrations/v0.js.map +0 -1
  100. package/dist/cjs/migrations/v1.d.ts +0 -1
  101. package/dist/cjs/migrations/v1.js +0 -32
  102. package/dist/cjs/migrations/v1.js.map +0 -1
  103. package/dist/cjs/migrations/v2.d.ts +0 -1
  104. package/dist/cjs/migrations/v2.js +0 -29
  105. package/dist/cjs/migrations/v2.js.map +0 -1
  106. package/dist/cjs/migrations/v3.d.ts +0 -1
  107. package/dist/cjs/migrations/v3.js +0 -15
  108. package/dist/cjs/migrations/v3.js.map +0 -1
  109. package/dist/cjs/migrations/v4.d.ts +0 -1
  110. package/dist/cjs/migrations/v4.js +0 -9
  111. package/dist/cjs/migrations/v4.js.map +0 -1
  112. package/dist/cjs/migrations/v5.d.ts +0 -2
  113. package/dist/cjs/migrations/v5.js +0 -37
  114. package/dist/cjs/migrations/v5.js.map +0 -1
  115. package/dist/cjs/migrations/v6.d.ts +0 -1
  116. package/dist/cjs/migrations/v6.js +0 -8
  117. package/dist/cjs/migrations/v6.js.map +0 -1
  118. package/dist/cjs/migrations/v7.d.ts +0 -1
  119. package/dist/cjs/migrations/v7.js +0 -10
  120. package/dist/cjs/migrations/v7.js.map +0 -1
  121. package/dist/cjs/migrations.d.ts +0 -6
  122. package/dist/cjs/migrations.js +0 -72
  123. package/dist/cjs/migrations.js.map +0 -1
  124. package/dist/cjs/migrations.test.d.ts +0 -1
  125. package/dist/cjs/migrations.test.js +0 -121
  126. package/dist/cjs/migrations.test.js.map +0 -1
  127. package/dist/cjs/types.d.ts +0 -23
  128. package/dist/cjs/types.js +0 -3
  129. package/dist/cjs/types.js.map +0 -1
  130. package/dist/esm/Baselines.d.ts +0 -16
  131. package/dist/esm/Baselines.js +0 -124
  132. package/dist/esm/Baselines.js.map +0 -1
  133. package/dist/esm/ClientConnection.d.ts +0 -20
  134. package/dist/esm/MessageSender.d.ts +0 -5
  135. package/dist/esm/OperationHistory.d.ts +0 -36
  136. package/dist/esm/OperationHistory.js +0 -161
  137. package/dist/esm/OperationHistory.js.map +0 -1
  138. package/dist/esm/Presence.d.ts +0 -15
  139. package/dist/esm/Profiles.d.ts +0 -10
  140. package/dist/esm/ReplicaKeepaliveTimers.d.ts +0 -17
  141. package/dist/esm/Replicas.d.ts +0 -25
  142. package/dist/esm/Replicas.js +0 -192
  143. package/dist/esm/Replicas.js.map +0 -1
  144. package/dist/esm/Server.d.ts +0 -180
  145. package/dist/esm/ServerLibrary.d.ts +0 -77
  146. package/dist/esm/TokenProvider.d.ts +0 -16
  147. package/dist/esm/TokenVerifier.d.ts +0 -16
  148. package/dist/esm/files/FileMetadata.d.ts +0 -28
  149. package/dist/esm/files/FileMetadata.js +0 -65
  150. package/dist/esm/files/FileMetadata.js.map +0 -1
  151. package/dist/esm/files/FileStorage.d.ts +0 -35
  152. package/dist/esm/index.d.ts +0 -9
  153. package/dist/esm/migrations/v0.d.ts +0 -1
  154. package/dist/esm/migrations/v0.js +0 -27
  155. package/dist/esm/migrations/v0.js.map +0 -1
  156. package/dist/esm/migrations/v1.d.ts +0 -1
  157. package/dist/esm/migrations/v1.js +0 -29
  158. package/dist/esm/migrations/v1.js.map +0 -1
  159. package/dist/esm/migrations/v2.d.ts +0 -1
  160. package/dist/esm/migrations/v2.js +0 -26
  161. package/dist/esm/migrations/v2.js.map +0 -1
  162. package/dist/esm/migrations/v3.d.ts +0 -1
  163. package/dist/esm/migrations/v3.js +0 -12
  164. package/dist/esm/migrations/v3.js.map +0 -1
  165. package/dist/esm/migrations/v4.d.ts +0 -1
  166. package/dist/esm/migrations/v4.js +0 -6
  167. package/dist/esm/migrations/v4.js.map +0 -1
  168. package/dist/esm/migrations/v5.d.ts +0 -2
  169. package/dist/esm/migrations/v5.js +0 -33
  170. package/dist/esm/migrations/v5.js.map +0 -1
  171. package/dist/esm/migrations/v6.d.ts +0 -1
  172. package/dist/esm/migrations/v6.js +0 -5
  173. package/dist/esm/migrations/v6.js.map +0 -1
  174. package/dist/esm/migrations/v7.d.ts +0 -1
  175. package/dist/esm/migrations/v7.js +0 -7
  176. package/dist/esm/migrations/v7.js.map +0 -1
  177. package/dist/esm/migrations.d.ts +0 -6
  178. package/dist/esm/migrations.js +0 -45
  179. package/dist/esm/migrations.js.map +0 -1
  180. package/dist/esm/migrations.test.d.ts +0 -1
  181. package/dist/esm/migrations.test.js +0 -116
  182. package/dist/esm/migrations.test.js.map +0 -1
  183. package/dist/esm/types.d.ts +0 -23
  184. package/dist/tsconfig-cjs.tsbuildinfo +0 -1
  185. package/dist/tsconfig.tsbuildinfo +0 -1
  186. package/src/Baselines.ts +0 -198
  187. package/src/OperationHistory.ts +0 -232
  188. package/src/Replicas.ts +0 -274
  189. package/src/files/FileMetadata.ts +0 -112
  190. package/src/migrations/v0.ts +0 -26
  191. package/src/migrations/v1.ts +0 -28
  192. package/src/migrations/v2.ts +0 -26
  193. package/src/migrations/v3.ts +0 -12
  194. package/src/migrations/v4.ts +0 -6
  195. package/src/migrations/v5.ts +0 -49
  196. package/src/migrations/v6.ts +0 -4
  197. package/src/migrations/v7.ts +0 -6
  198. package/src/migrations.test.ts +0 -133
  199. 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 { IncomingMessage, Server as HttpServer, ServerResponse } from 'http';
14
- import { WebSocketServer, WebSocket } from 'ws';
15
- import { UserProfileLoader, UserProfiles } from './Profiles.js';
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 internal, { Readable } from 'stream';
25
- import { FileMetadata, FileMetadataConfig } from './files/FileMetadata.js';
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 { migrations } from './migrations.js';
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
- * The path to your SQLite database.
41
+ * Your storage implementation. See the sqlStorage export for a built-in
42
+ * option.
49
43
  */
50
- databaseFile: string;
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?: FileMetadataConfig;
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 fileMetadata;
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
- const db = create(options.databaseFile);
149
- migrations(db);
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
- db,
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.fileMetadata.put(info.libraryId, lofiFileInfo);
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
- const fileInfo = this.fileMetadata.get(info.libraryId, id);
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
- console.warn('HTTP server close timed out');
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
- console.warn('Socket server close timed out');
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.library.close();
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
+ });