@korajs/test 0.0.0-canary-20261008174129
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/LICENSE +21 -0
- package/dist/index.cjs +973 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +493 -0
- package/dist/index.d.ts +493 -0
- package/dist/index.js +942 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
// src/test-network.ts
|
|
2
|
+
import { mkdtempSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { createServerTransportPair } from "@korajs/server/internal";
|
|
6
|
+
import { ChaosTransport } from "@korajs/sync";
|
|
7
|
+
|
|
8
|
+
// src/test-device.ts
|
|
9
|
+
import { SimpleEventEmitter } from "@korajs/core/internal";
|
|
10
|
+
import { MergeEngine } from "@korajs/merge";
|
|
11
|
+
import {
|
|
12
|
+
MemoryBlobStore,
|
|
13
|
+
Store,
|
|
14
|
+
createRemoteChunkProvider,
|
|
15
|
+
prepareBlobForSend,
|
|
16
|
+
putBlobForTransfer,
|
|
17
|
+
receiveBlob,
|
|
18
|
+
resolveBlobManifest,
|
|
19
|
+
serveBlobChunks
|
|
20
|
+
} from "@korajs/store";
|
|
21
|
+
import { BetterSqlite3Adapter } from "@korajs/store/better-sqlite3";
|
|
22
|
+
import { SyncEncryptor, SyncEngine, validateEncryptedRelations } from "@korajs/sync";
|
|
23
|
+
import {
|
|
24
|
+
ApplyPipeline,
|
|
25
|
+
MergeAwareSyncStore,
|
|
26
|
+
StoreQueueStorage,
|
|
27
|
+
StoreSyncStatePersistence,
|
|
28
|
+
createSyncEngineChunkPort,
|
|
29
|
+
wireAuditPersistence,
|
|
30
|
+
wireBlobUpload
|
|
31
|
+
} from "korajs/testing";
|
|
32
|
+
var ReconnectingClientTransport = class {
|
|
33
|
+
constructor(inner, createPair, server) {
|
|
34
|
+
this.inner = inner;
|
|
35
|
+
this.createPair = createPair;
|
|
36
|
+
this.server = server;
|
|
37
|
+
}
|
|
38
|
+
inner;
|
|
39
|
+
createPair;
|
|
40
|
+
server;
|
|
41
|
+
messageHandler = null;
|
|
42
|
+
closeHandler = null;
|
|
43
|
+
errorHandler = null;
|
|
44
|
+
connectedOnce = false;
|
|
45
|
+
async connect(url, options) {
|
|
46
|
+
if (this.connectedOnce && !this.inner.isConnected()) {
|
|
47
|
+
const previous = this.inner;
|
|
48
|
+
previous.onMessage(() => {
|
|
49
|
+
});
|
|
50
|
+
previous.onClose(() => {
|
|
51
|
+
});
|
|
52
|
+
previous.onError(() => {
|
|
53
|
+
});
|
|
54
|
+
const { client, serverTransport } = this.createPair();
|
|
55
|
+
this.inner = client;
|
|
56
|
+
this.wire();
|
|
57
|
+
this.server.handleConnection(serverTransport);
|
|
58
|
+
}
|
|
59
|
+
this.connectedOnce = true;
|
|
60
|
+
await this.inner.connect(url, options);
|
|
61
|
+
}
|
|
62
|
+
disconnect() {
|
|
63
|
+
return this.inner.disconnect();
|
|
64
|
+
}
|
|
65
|
+
send(message) {
|
|
66
|
+
this.inner.send(message);
|
|
67
|
+
}
|
|
68
|
+
onMessage(handler) {
|
|
69
|
+
this.messageHandler = handler;
|
|
70
|
+
this.wire();
|
|
71
|
+
}
|
|
72
|
+
onClose(handler) {
|
|
73
|
+
this.closeHandler = handler;
|
|
74
|
+
this.wire();
|
|
75
|
+
}
|
|
76
|
+
onError(handler) {
|
|
77
|
+
this.errorHandler = handler;
|
|
78
|
+
this.wire();
|
|
79
|
+
}
|
|
80
|
+
isConnected() {
|
|
81
|
+
return this.inner.isConnected();
|
|
82
|
+
}
|
|
83
|
+
wire() {
|
|
84
|
+
this.inner.onMessage((message) => this.messageHandler?.(message));
|
|
85
|
+
this.inner.onClose((reason) => this.closeHandler?.(reason));
|
|
86
|
+
this.inner.onError((error) => this.errorHandler?.(error));
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
var TestDevice = class {
|
|
90
|
+
name;
|
|
91
|
+
store;
|
|
92
|
+
emitter;
|
|
93
|
+
/** Content-addressed blob store for out-of-band blob bytes (chunks + full blobs). */
|
|
94
|
+
blobStore = new MemoryBlobStore();
|
|
95
|
+
schema;
|
|
96
|
+
server;
|
|
97
|
+
encryption;
|
|
98
|
+
batchSize;
|
|
99
|
+
encryptor = null;
|
|
100
|
+
mergeEngine;
|
|
101
|
+
createTransportPair;
|
|
102
|
+
adapter;
|
|
103
|
+
dbPath;
|
|
104
|
+
syncSchemaVersion;
|
|
105
|
+
operationTransforms;
|
|
106
|
+
scopeExit;
|
|
107
|
+
principal;
|
|
108
|
+
reconnectable;
|
|
109
|
+
applyPipeline = null;
|
|
110
|
+
syncEngine = null;
|
|
111
|
+
blobChunkProvider = null;
|
|
112
|
+
currentTransport = null;
|
|
113
|
+
unsubscribeSync = null;
|
|
114
|
+
unsubscribeAudit = null;
|
|
115
|
+
closing = false;
|
|
116
|
+
constructor(options) {
|
|
117
|
+
this.name = options.name;
|
|
118
|
+
this.schema = options.schema;
|
|
119
|
+
this.server = options.server;
|
|
120
|
+
this.createTransportPair = options.createTransportPair;
|
|
121
|
+
this.dbPath = `${options.tmpDir}/test-device-${options.name}.db`;
|
|
122
|
+
this.syncSchemaVersion = options.syncSchemaVersion ?? options.schema.version;
|
|
123
|
+
this.operationTransforms = options.operationTransforms ?? [];
|
|
124
|
+
this.scopeExit = options.scopeExit;
|
|
125
|
+
this.principal = options.principal;
|
|
126
|
+
this.reconnectable = options.reconnectable === true;
|
|
127
|
+
this.encryption = options.encryption;
|
|
128
|
+
this.batchSize = options.batchSize;
|
|
129
|
+
validateEncryptedRelations(options.schema, options.encryption?.config);
|
|
130
|
+
this.emitter = new SimpleEventEmitter();
|
|
131
|
+
this.mergeEngine = new MergeEngine();
|
|
132
|
+
this.adapter = new BetterSqlite3Adapter(this.dbPath);
|
|
133
|
+
this.store = new Store({
|
|
134
|
+
schema: options.schema,
|
|
135
|
+
adapter: this.adapter,
|
|
136
|
+
emitter: this.emitter,
|
|
137
|
+
materialization: options.legacyMerge === true ? "legacy" : "fold",
|
|
138
|
+
...options.operationTransforms && options.operationTransforms.length > 0 ? { operationTransforms: options.operationTransforms } : {},
|
|
139
|
+
...options.maxOperationBytes !== void 0 ? { maxOperationBytes: options.maxOperationBytes } : {},
|
|
140
|
+
...options.nodeId !== void 0 ? { nodeId: options.nodeId } : {}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Open the store (must be called before sync or collection operations).
|
|
145
|
+
*/
|
|
146
|
+
async open() {
|
|
147
|
+
await this.store.open();
|
|
148
|
+
this.applyPipeline = new ApplyPipeline({
|
|
149
|
+
store: this.store,
|
|
150
|
+
mergeEngine: this.mergeEngine,
|
|
151
|
+
emitter: this.emitter
|
|
152
|
+
});
|
|
153
|
+
this.store.setLocalMutationHandler(this.applyPipeline);
|
|
154
|
+
this.unsubscribeAudit = wireAuditPersistence(this.store, this.emitter);
|
|
155
|
+
const principal = this.principal?.();
|
|
156
|
+
if (principal) await this.store.bindPrincipal(principal);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The signed-in user changed (RT-42), as an auth binding reports it: like createApp,
|
|
160
|
+
* end the live session and bind the store to the new user before the next write.
|
|
161
|
+
*/
|
|
162
|
+
async authChanged() {
|
|
163
|
+
if (this.syncEngine) {
|
|
164
|
+
await this.syncEngine.refreshPrincipal();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const principal = this.principal?.();
|
|
168
|
+
if (principal) await this.store.bindPrincipal(principal);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Connect to the test server and perform initial sync.
|
|
172
|
+
* If already connected, flushes any pending operations.
|
|
173
|
+
*/
|
|
174
|
+
async sync() {
|
|
175
|
+
if (this.syncEngine && this.currentTransport?.isConnected()) {
|
|
176
|
+
await this.waitForPendingOps();
|
|
177
|
+
await this.waitForSettled();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const created = this.createTransportPair();
|
|
181
|
+
const { serverTransport } = created;
|
|
182
|
+
const client = this.reconnectable ? new ReconnectingClientTransport(created.client, this.createTransportPair, this.server) : created.client;
|
|
183
|
+
this.currentTransport = client;
|
|
184
|
+
if (this.encryption && !this.encryptor) {
|
|
185
|
+
this.encryptor = await SyncEncryptor.create(
|
|
186
|
+
this.encryption.config,
|
|
187
|
+
this.encryption.salt,
|
|
188
|
+
this.encryption.iterations
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const conflictHandler = {};
|
|
192
|
+
const syncStore = new MergeAwareSyncStore(this.store, this.mergeEngine, this.emitter, {
|
|
193
|
+
onMergeConflict: () => conflictHandler.fn?.()
|
|
194
|
+
});
|
|
195
|
+
this.syncEngine = new SyncEngine({
|
|
196
|
+
transport: client,
|
|
197
|
+
store: syncStore,
|
|
198
|
+
queueStorage: new StoreQueueStorage(this.adapter),
|
|
199
|
+
syncState: new StoreSyncStatePersistence(this.store),
|
|
200
|
+
config: {
|
|
201
|
+
url: "ws://test-network",
|
|
202
|
+
schemaVersion: this.syncSchemaVersion,
|
|
203
|
+
operationTransforms: this.operationTransforms.length > 0 ? this.operationTransforms : void 0,
|
|
204
|
+
...this.scopeExit ? { scopeExit: this.scopeExit } : {},
|
|
205
|
+
...this.principal ? { principal: async () => this.principal ? this.principal() : null } : {},
|
|
206
|
+
...this.encryption ? { encryption: this.encryption.config } : {},
|
|
207
|
+
...this.batchSize !== void 0 ? { batchSize: this.batchSize } : {}
|
|
208
|
+
},
|
|
209
|
+
emitter: this.emitter,
|
|
210
|
+
...this.encryptor ? { encryptor: this.encryptor } : {}
|
|
211
|
+
});
|
|
212
|
+
conflictHandler.fn = () => this.syncEngine?.recordConflict();
|
|
213
|
+
const chunkPort = createSyncEngineChunkPort(this.syncEngine);
|
|
214
|
+
serveBlobChunks(chunkPort, this.blobStore);
|
|
215
|
+
this.blobChunkProvider = createRemoteChunkProvider(chunkPort);
|
|
216
|
+
wireBlobUpload(this.emitter, this.syncEngine, this.blobStore);
|
|
217
|
+
const engine = this.syncEngine;
|
|
218
|
+
this.unsubscribeSync = this.emitter.on("operation:created", (event) => {
|
|
219
|
+
if (!this.closing && this.syncEngine === engine && this.currentTransport?.isConnected()) {
|
|
220
|
+
this.syncEngine.pushOperation(event.operation).catch(() => {
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
this.server.handleConnection(serverTransport);
|
|
225
|
+
await this.syncEngine.start();
|
|
226
|
+
await this.waitForSettled();
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Disconnect from the test server.
|
|
230
|
+
*/
|
|
231
|
+
async disconnect() {
|
|
232
|
+
if (this.unsubscribeSync) {
|
|
233
|
+
this.unsubscribeSync();
|
|
234
|
+
this.unsubscribeSync = null;
|
|
235
|
+
}
|
|
236
|
+
if (this.syncEngine) {
|
|
237
|
+
await this.syncEngine.stop();
|
|
238
|
+
this.syncEngine = null;
|
|
239
|
+
}
|
|
240
|
+
this.blobChunkProvider = null;
|
|
241
|
+
this.currentTransport = null;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Reconnect to the test server after a disconnect.
|
|
245
|
+
*/
|
|
246
|
+
async reconnect() {
|
|
247
|
+
await this.sync();
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Get a collection accessor for performing CRUD operations.
|
|
251
|
+
*/
|
|
252
|
+
collection(name) {
|
|
253
|
+
return this.store.collection(name);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Get all records from a collection (convenience method).
|
|
257
|
+
*/
|
|
258
|
+
async getState(collectionName) {
|
|
259
|
+
const accessor = this.store.collection(collectionName);
|
|
260
|
+
return accessor.where({}).exec();
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Get the device's node ID.
|
|
264
|
+
*/
|
|
265
|
+
getNodeId() {
|
|
266
|
+
return this.store.getNodeId();
|
|
267
|
+
}
|
|
268
|
+
/** Exposes the sync engine for integration tests (e.g. doc channel, chaos). */
|
|
269
|
+
getSyncEngine() {
|
|
270
|
+
return this.syncEngine;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Operations the server rejected for this device that have not been reconciled.
|
|
274
|
+
* Empty when the device has never connected.
|
|
275
|
+
*/
|
|
276
|
+
async getRejectedOperations() {
|
|
277
|
+
if (!this.syncEngine) return [];
|
|
278
|
+
return this.syncEngine.getRejectedOperations();
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Stage a blob's bytes into this device's blob store, splitting them into
|
|
282
|
+
* content-addressed chunks this device can then serve to peers over the sync
|
|
283
|
+
* connection. Returns the manifest (blob hash + ordered chunk hashes) needed
|
|
284
|
+
* to pull the blob elsewhere.
|
|
285
|
+
*/
|
|
286
|
+
async stageBlob(bytes, options) {
|
|
287
|
+
const { manifest } = await prepareBlobForSend(bytes, this.blobStore, options);
|
|
288
|
+
return manifest;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Store a blob for transfer the way `app.blobs.put` does: stage chunks, store
|
|
292
|
+
* the full blob, and store the manifest as its own content-addressed object.
|
|
293
|
+
* The returned reference carries a `manifestHash`, so a peer can pull the bytes
|
|
294
|
+
* knowing only the reference.
|
|
295
|
+
*/
|
|
296
|
+
async putBlob(bytes, options) {
|
|
297
|
+
return putBlobForTransfer(this.blobStore, bytes, options);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Pull a blob's bytes over the live connection knowing only its reference: the
|
|
301
|
+
* manifest is resolved by `ref.manifestHash` first, then the chunks are fetched.
|
|
302
|
+
*/
|
|
303
|
+
async pullBlobByRef(ref) {
|
|
304
|
+
if (!this.blobChunkProvider) {
|
|
305
|
+
throw new Error("Cannot pull a blob before the device has connected (call sync() first)");
|
|
306
|
+
}
|
|
307
|
+
const manifest = await resolveBlobManifest(this.blobChunkProvider, ref);
|
|
308
|
+
return receiveBlob(manifest, this.blobChunkProvider, {
|
|
309
|
+
chunkStore: this.blobStore,
|
|
310
|
+
blobStore: this.blobStore
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Pull a blob's bytes from peers over the live sync connection. Requests only
|
|
315
|
+
* the chunks this device is missing, reassembles them, and verifies integrity
|
|
316
|
+
* against the manifest's blob hash before storing the full blob locally.
|
|
317
|
+
*/
|
|
318
|
+
async pullBlob(manifest) {
|
|
319
|
+
if (!this.blobChunkProvider) {
|
|
320
|
+
throw new Error("Cannot pull a blob before the device has connected (call sync() first)");
|
|
321
|
+
}
|
|
322
|
+
return receiveBlob(manifest, this.blobChunkProvider, {
|
|
323
|
+
chunkStore: this.blobStore,
|
|
324
|
+
blobStore: this.blobStore
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
/** Read fully-assembled blob bytes from this device's store, or null if absent. */
|
|
328
|
+
async getBlobBytes(hash) {
|
|
329
|
+
return this.blobStore.get(hash);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Get the device's version vector.
|
|
333
|
+
*/
|
|
334
|
+
getVersionVector() {
|
|
335
|
+
return this.store.getVersionVector();
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Check if the device is currently connected to the server.
|
|
339
|
+
*/
|
|
340
|
+
isConnected() {
|
|
341
|
+
return this.currentTransport?.isConnected() ?? false;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Close the device, releasing all resources.
|
|
345
|
+
*/
|
|
346
|
+
async close() {
|
|
347
|
+
this.closing = true;
|
|
348
|
+
if (this.unsubscribeAudit) {
|
|
349
|
+
this.unsubscribeAudit();
|
|
350
|
+
this.unsubscribeAudit = null;
|
|
351
|
+
}
|
|
352
|
+
await this.disconnect();
|
|
353
|
+
await this.store.close();
|
|
354
|
+
this.emitter.clear();
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Wait for in-flight sync operations to settle.
|
|
358
|
+
* In-memory transports are near-synchronous, but apply/relay work is async.
|
|
359
|
+
*/
|
|
360
|
+
async waitForSettled() {
|
|
361
|
+
for (let i = 0; i < 15; i++) {
|
|
362
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Wait for all pending outbound operations to be acknowledged.
|
|
367
|
+
*/
|
|
368
|
+
async waitForPendingOps() {
|
|
369
|
+
if (!this.syncEngine) return;
|
|
370
|
+
const maxWait = 2e3;
|
|
371
|
+
const start = Date.now();
|
|
372
|
+
while (Date.now() - start < maxWait) {
|
|
373
|
+
const status = this.syncEngine.getStatus();
|
|
374
|
+
if (status.pendingOperations === 0) return;
|
|
375
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// src/test-server.ts
|
|
381
|
+
import { MemoryServerStore } from "@korajs/server";
|
|
382
|
+
import { KoraSyncServer } from "@korajs/server";
|
|
383
|
+
import { createMemoryServerBlobStore } from "@korajs/store";
|
|
384
|
+
var TestServer = class {
|
|
385
|
+
store;
|
|
386
|
+
/** Resolves once the store has the schema (needed before a SQL store is used). */
|
|
387
|
+
ready;
|
|
388
|
+
/** The server's central blob store, present when `blobStorage` was enabled. */
|
|
389
|
+
blobStore;
|
|
390
|
+
syncServer;
|
|
391
|
+
constructor(schema, options) {
|
|
392
|
+
this.store = options?.store ?? new MemoryServerStore();
|
|
393
|
+
const schemaVersion = options?.schemaVersion ?? schema.version;
|
|
394
|
+
const blob = options?.blobStorage ? createMemoryServerBlobStore() : null;
|
|
395
|
+
this.blobStore = blob?.store ?? null;
|
|
396
|
+
this.syncServer = new KoraSyncServer({
|
|
397
|
+
store: this.store,
|
|
398
|
+
schemaVersion,
|
|
399
|
+
supportedSchemaVersions: options?.supportedSchemaVersions ?? {
|
|
400
|
+
min: schemaVersion,
|
|
401
|
+
max: schemaVersion
|
|
402
|
+
},
|
|
403
|
+
...options?.operationTransforms ? { operationTransforms: options.operationTransforms } : {},
|
|
404
|
+
...blob ? blob.callbacks : {},
|
|
405
|
+
...options?.validateOperation ? { validateOperation: options.validateOperation } : {},
|
|
406
|
+
...options?.encryption ? { encryption: options.encryption } : {}
|
|
407
|
+
});
|
|
408
|
+
this.ready = this.store.setSchema(
|
|
409
|
+
schema,
|
|
410
|
+
options?.operationTransforms ? { operationTransforms: options.operationTransforms } : {}
|
|
411
|
+
);
|
|
412
|
+
this.ready.catch(() => {
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Register a client connection transport with the server.
|
|
417
|
+
* Returns the session ID assigned by the server.
|
|
418
|
+
*/
|
|
419
|
+
handleConnection(transport) {
|
|
420
|
+
return this.syncServer.handleConnection(transport);
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Get all operations stored on the server.
|
|
424
|
+
*/
|
|
425
|
+
getAllOperations() {
|
|
426
|
+
if (!(this.store instanceof MemoryServerStore)) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
"TestServer.getAllOperations() reads the memory store synchronously; use store.getOperationsAfterDelivery() with a SQL store"
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return this.store.getAllOperations();
|
|
432
|
+
}
|
|
433
|
+
/** The trusted server-side write API (route context): writes authored by the server node. */
|
|
434
|
+
getKoraContext() {
|
|
435
|
+
return this.syncServer.getKoraContext();
|
|
436
|
+
}
|
|
437
|
+
/** The node ids the server's stores fold with and advertise in the handshake. */
|
|
438
|
+
get authoritativeNodeIds() {
|
|
439
|
+
return this.syncServer.authoritativeNodeIds;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Get the number of connected clients.
|
|
443
|
+
*/
|
|
444
|
+
getConnectionCount() {
|
|
445
|
+
return this.syncServer.getConnectionCount();
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Retransmit relay batches connected clients have not acknowledged. Lets a test
|
|
449
|
+
* deterministically trigger the redelivery that the server also runs on a timer,
|
|
450
|
+
* without depending on wall-clock time. `staleMs` of 0 resends every pending relay.
|
|
451
|
+
*/
|
|
452
|
+
retransmitPendingRelays(staleMs = 0) {
|
|
453
|
+
this.syncServer.retransmitPendingRelays(staleMs);
|
|
454
|
+
}
|
|
455
|
+
/** Every blob reference still reachable from live records on the server. */
|
|
456
|
+
getLiveBlobRefs() {
|
|
457
|
+
return this.syncServer.getLiveBlobRefs();
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Shut down the server and close all sessions.
|
|
461
|
+
*/
|
|
462
|
+
async close() {
|
|
463
|
+
await this.syncServer.stop();
|
|
464
|
+
await this.store.close();
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// src/test-network.ts
|
|
469
|
+
async function createTestNetwork(schema, options) {
|
|
470
|
+
const deviceCount = options?.devices ?? 2;
|
|
471
|
+
const deviceNames = options?.deviceNames ?? Array.from({ length: deviceCount }, (_, i) => `device-${i}`);
|
|
472
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "kora-test-"));
|
|
473
|
+
const server = new TestServer(schema, {
|
|
474
|
+
blobStorage: options?.blobStorage,
|
|
475
|
+
validateOperation: options?.validateOperation,
|
|
476
|
+
...options?.serverStore ? { store: options.serverStore } : {},
|
|
477
|
+
...options?.serverEncryption ? { encryption: options.serverEncryption } : {}
|
|
478
|
+
});
|
|
479
|
+
await server.ready;
|
|
480
|
+
const devices = [];
|
|
481
|
+
for (const name of deviceNames) {
|
|
482
|
+
const device = new TestDevice({
|
|
483
|
+
name,
|
|
484
|
+
schema,
|
|
485
|
+
server,
|
|
486
|
+
createTransportPair: () => {
|
|
487
|
+
const pair = createServerTransportPair();
|
|
488
|
+
const client = pair.client;
|
|
489
|
+
const chaos = options?.chaos;
|
|
490
|
+
const base = {
|
|
491
|
+
client: chaos ? new ChaosTransport(client, chaos) : client,
|
|
492
|
+
serverTransport: pair.server
|
|
493
|
+
};
|
|
494
|
+
return options?.wrapTransport ? options.wrapTransport(base) : base;
|
|
495
|
+
},
|
|
496
|
+
tmpDir,
|
|
497
|
+
...options?.legacyMerge ? { legacyMerge: true } : {},
|
|
498
|
+
...options?.encryption ? { encryption: options.encryption } : {},
|
|
499
|
+
...options?.deviceMaxOperationBytes !== void 0 ? { maxOperationBytes: options.deviceMaxOperationBytes } : {}
|
|
500
|
+
});
|
|
501
|
+
await device.open();
|
|
502
|
+
devices.push(device);
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
server,
|
|
506
|
+
devices,
|
|
507
|
+
tmpDir,
|
|
508
|
+
async close() {
|
|
509
|
+
for (const device of devices) {
|
|
510
|
+
await device.close();
|
|
511
|
+
}
|
|
512
|
+
await server.close();
|
|
513
|
+
try {
|
|
514
|
+
const { rmSync } = await import("fs");
|
|
515
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
516
|
+
} catch {
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
async function createMixedTestNetwork(serverSchema, serverOptions, deviceConfigs) {
|
|
522
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "kora-test-"));
|
|
523
|
+
const server = new TestServer(serverSchema, serverOptions);
|
|
524
|
+
const devices = [];
|
|
525
|
+
for (const config of deviceConfigs) {
|
|
526
|
+
const device = new TestDevice({
|
|
527
|
+
name: config.name,
|
|
528
|
+
schema: config.schema,
|
|
529
|
+
server,
|
|
530
|
+
syncSchemaVersion: config.syncSchemaVersion,
|
|
531
|
+
operationTransforms: config.operationTransforms,
|
|
532
|
+
createTransportPair: () => {
|
|
533
|
+
const pair = createServerTransportPair();
|
|
534
|
+
return {
|
|
535
|
+
client: pair.client,
|
|
536
|
+
serverTransport: pair.server
|
|
537
|
+
};
|
|
538
|
+
},
|
|
539
|
+
tmpDir
|
|
540
|
+
});
|
|
541
|
+
await device.open();
|
|
542
|
+
devices.push(device);
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
server,
|
|
546
|
+
devices,
|
|
547
|
+
tmpDir,
|
|
548
|
+
async close() {
|
|
549
|
+
for (const device of devices) {
|
|
550
|
+
await device.close();
|
|
551
|
+
}
|
|
552
|
+
await server.close();
|
|
553
|
+
try {
|
|
554
|
+
const { rmSync } = await import("fs");
|
|
555
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
556
|
+
} catch {
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// src/assertions.ts
|
|
563
|
+
async function expectConverged(devices, schema) {
|
|
564
|
+
if (devices.length < 2) return;
|
|
565
|
+
const result = await checkConvergence(devices, schema);
|
|
566
|
+
if (!result.converged) {
|
|
567
|
+
const details = result.differences.map((d) => {
|
|
568
|
+
const parts = [
|
|
569
|
+
` Collection "${d.collection}" differs between ${d.deviceA} and ${d.deviceB}:`
|
|
570
|
+
];
|
|
571
|
+
if (d.missingInB.length > 0) {
|
|
572
|
+
parts.push(` Missing in ${d.deviceB}: ${d.missingInB.join(", ")}`);
|
|
573
|
+
}
|
|
574
|
+
if (d.missingInA.length > 0) {
|
|
575
|
+
parts.push(` Missing in ${d.deviceA}: ${d.missingInA.join(", ")}`);
|
|
576
|
+
}
|
|
577
|
+
for (const fd of d.fieldDifferences) {
|
|
578
|
+
parts.push(
|
|
579
|
+
` Record "${fd.recordId}" field "${fd.field}": ${JSON.stringify(fd.valueInA)} vs ${JSON.stringify(fd.valueInB)}`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
return parts.join("\n");
|
|
583
|
+
}).join("\n");
|
|
584
|
+
throw new Error(`Devices have not converged:
|
|
585
|
+
${details}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async function expectConvergedEventually(devices, schema, options) {
|
|
589
|
+
if (devices.length < 2) return;
|
|
590
|
+
const timeoutMs = options?.timeoutMs ?? 5e3;
|
|
591
|
+
const intervalMs = options?.intervalMs ?? 25;
|
|
592
|
+
const deadline = Date.now() + timeoutMs;
|
|
593
|
+
while (Date.now() < deadline) {
|
|
594
|
+
const result = await checkConvergence(devices, schema);
|
|
595
|
+
if (result.converged) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
599
|
+
}
|
|
600
|
+
await expectConverged(devices, schema);
|
|
601
|
+
}
|
|
602
|
+
async function checkConvergence(devices, schema) {
|
|
603
|
+
const differences = [];
|
|
604
|
+
const collectionNames = Object.keys(schema.collections);
|
|
605
|
+
for (let i = 0; i < devices.length - 1; i++) {
|
|
606
|
+
for (let j = i + 1; j < devices.length; j++) {
|
|
607
|
+
const deviceA = devices[i];
|
|
608
|
+
const deviceB = devices[j];
|
|
609
|
+
for (const collection of collectionNames) {
|
|
610
|
+
const stateA = await deviceA.getState(collection);
|
|
611
|
+
const stateB = await deviceB.getState(collection);
|
|
612
|
+
const diff = compareCollectionStates(collection, deviceA.name, deviceB.name, stateA, stateB);
|
|
613
|
+
if (diff) {
|
|
614
|
+
differences.push(diff);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
converged: differences.length === 0,
|
|
621
|
+
differences
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function compareCollectionStates(collection, nameA, nameB, stateA, stateB) {
|
|
625
|
+
const mapA = new Map(stateA.map((r) => [r.id, r]));
|
|
626
|
+
const mapB = new Map(stateB.map((r) => [r.id, r]));
|
|
627
|
+
const missingInB = [];
|
|
628
|
+
const missingInA = [];
|
|
629
|
+
const fieldDifferences = [];
|
|
630
|
+
for (const [id, recordA] of mapA) {
|
|
631
|
+
const recordB = mapB.get(id);
|
|
632
|
+
if (!recordB) {
|
|
633
|
+
missingInB.push(id);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
const allFields = /* @__PURE__ */ new Set([
|
|
637
|
+
...Object.keys(recordA).filter((k) => !k.startsWith("_")),
|
|
638
|
+
...Object.keys(recordB).filter((k) => !k.startsWith("_"))
|
|
639
|
+
]);
|
|
640
|
+
for (const field of allFields) {
|
|
641
|
+
if (field === "id") continue;
|
|
642
|
+
const valA = recordA[field];
|
|
643
|
+
const valB = recordB[field];
|
|
644
|
+
if (!deepEqual(valA, valB)) {
|
|
645
|
+
fieldDifferences.push({
|
|
646
|
+
recordId: id,
|
|
647
|
+
field,
|
|
648
|
+
valueInA: valA,
|
|
649
|
+
valueInB: valB
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
for (const id of mapB.keys()) {
|
|
655
|
+
if (!mapA.has(id)) {
|
|
656
|
+
missingInA.push(id);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
if (missingInB.length === 0 && missingInA.length === 0 && fieldDifferences.length === 0) {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
return {
|
|
663
|
+
collection,
|
|
664
|
+
deviceA: nameA,
|
|
665
|
+
deviceB: nameB,
|
|
666
|
+
missingInB,
|
|
667
|
+
missingInA,
|
|
668
|
+
fieldDifferences
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
function deepEqual(a, b) {
|
|
672
|
+
if (a === b) return true;
|
|
673
|
+
if (a === null || b === null) return false;
|
|
674
|
+
if (a === void 0 || b === void 0) return false;
|
|
675
|
+
if (typeof a !== typeof b) return false;
|
|
676
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
677
|
+
if (a.length !== b.length) return false;
|
|
678
|
+
return a.every((val, i) => deepEqual(val, b[i]));
|
|
679
|
+
}
|
|
680
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
681
|
+
const keysA = Object.keys(a);
|
|
682
|
+
const keysB = Object.keys(b);
|
|
683
|
+
if (keysA.length !== keysB.length) return false;
|
|
684
|
+
return keysA.every(
|
|
685
|
+
(key) => deepEqual(a[key], b[key])
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// src/protobuf-wire-transport.ts
|
|
692
|
+
import { ProtobufMessageSerializer } from "@korajs/sync";
|
|
693
|
+
var PROTOBUF_WIRE_TYPES = /* @__PURE__ */ new Set([
|
|
694
|
+
"handshake",
|
|
695
|
+
"handshake-response",
|
|
696
|
+
"operation-batch",
|
|
697
|
+
"acknowledgment",
|
|
698
|
+
"error"
|
|
699
|
+
]);
|
|
700
|
+
function roundTripThroughProtobuf(message, serializer) {
|
|
701
|
+
if (!PROTOBUF_WIRE_TYPES.has(message.type)) {
|
|
702
|
+
return structuredClone(message);
|
|
703
|
+
}
|
|
704
|
+
const encoded = serializer.encode(message);
|
|
705
|
+
if (!(encoded instanceof Uint8Array)) {
|
|
706
|
+
throw new Error("Expected the protobuf serializer to encode to bytes");
|
|
707
|
+
}
|
|
708
|
+
return serializer.decode(encoded);
|
|
709
|
+
}
|
|
710
|
+
var ProtobufWireClientTransport = class {
|
|
711
|
+
constructor(inner, serializer) {
|
|
712
|
+
this.inner = inner;
|
|
713
|
+
this.serializer = serializer;
|
|
714
|
+
}
|
|
715
|
+
inner;
|
|
716
|
+
serializer;
|
|
717
|
+
connect(url, options) {
|
|
718
|
+
return this.inner.connect(url, options);
|
|
719
|
+
}
|
|
720
|
+
disconnect() {
|
|
721
|
+
return this.inner.disconnect();
|
|
722
|
+
}
|
|
723
|
+
send(message) {
|
|
724
|
+
this.inner.send(roundTripThroughProtobuf(message, this.serializer));
|
|
725
|
+
}
|
|
726
|
+
onMessage(handler) {
|
|
727
|
+
this.inner.onMessage(handler);
|
|
728
|
+
}
|
|
729
|
+
onClose(handler) {
|
|
730
|
+
this.inner.onClose(handler);
|
|
731
|
+
}
|
|
732
|
+
onError(handler) {
|
|
733
|
+
this.inner.onError(handler);
|
|
734
|
+
}
|
|
735
|
+
isConnected() {
|
|
736
|
+
return this.inner.isConnected();
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
var ProtobufWireServerTransport = class {
|
|
740
|
+
constructor(inner, serializer) {
|
|
741
|
+
this.inner = inner;
|
|
742
|
+
this.serializer = serializer;
|
|
743
|
+
}
|
|
744
|
+
inner;
|
|
745
|
+
serializer;
|
|
746
|
+
send(message) {
|
|
747
|
+
this.inner.send(roundTripThroughProtobuf(message, this.serializer));
|
|
748
|
+
}
|
|
749
|
+
onMessage(handler) {
|
|
750
|
+
this.inner.onMessage(handler);
|
|
751
|
+
}
|
|
752
|
+
onClose(handler) {
|
|
753
|
+
this.inner.onClose(handler);
|
|
754
|
+
}
|
|
755
|
+
onError(handler) {
|
|
756
|
+
this.inner.onError(handler);
|
|
757
|
+
}
|
|
758
|
+
isConnected() {
|
|
759
|
+
return this.inner.isConnected();
|
|
760
|
+
}
|
|
761
|
+
close(code, reason) {
|
|
762
|
+
this.inner.close(code, reason);
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
function wrapTransportPairWithProtobufWire(pair) {
|
|
766
|
+
const serializer = new ProtobufMessageSerializer();
|
|
767
|
+
return {
|
|
768
|
+
client: new ProtobufWireClientTransport(pair.client, serializer),
|
|
769
|
+
serverTransport: new ProtobufWireServerTransport(pair.serverTransport, serializer)
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// src/server-clock-transport.ts
|
|
774
|
+
var ServerClockServerTransport = class {
|
|
775
|
+
constructor(inner, serverNow) {
|
|
776
|
+
this.inner = inner;
|
|
777
|
+
this.serverNow = serverNow;
|
|
778
|
+
}
|
|
779
|
+
inner;
|
|
780
|
+
serverNow;
|
|
781
|
+
send(message) {
|
|
782
|
+
if (message.type === "handshake-response") {
|
|
783
|
+
this.inner.send({ ...message, serverTime: this.serverNow() });
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
this.inner.send(message);
|
|
787
|
+
}
|
|
788
|
+
onMessage(handler) {
|
|
789
|
+
this.inner.onMessage(handler);
|
|
790
|
+
}
|
|
791
|
+
onClose(handler) {
|
|
792
|
+
this.inner.onClose(handler);
|
|
793
|
+
}
|
|
794
|
+
onError(handler) {
|
|
795
|
+
this.inner.onError(handler);
|
|
796
|
+
}
|
|
797
|
+
isConnected() {
|
|
798
|
+
return this.inner.isConnected();
|
|
799
|
+
}
|
|
800
|
+
close(code, reason) {
|
|
801
|
+
this.inner.close(code, reason);
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
function wrapTransportPairWithServerClock(pair, serverNow) {
|
|
805
|
+
return {
|
|
806
|
+
client: pair.client,
|
|
807
|
+
serverTransport: new ServerClockServerTransport(pair.serverTransport, serverNow)
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// src/index.ts
|
|
812
|
+
import { ChaosTransport as ChaosTransport2 } from "@korajs/sync";
|
|
813
|
+
|
|
814
|
+
// src/test-auth.ts
|
|
815
|
+
function testJwt(userId, generation) {
|
|
816
|
+
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
817
|
+
return `${encode({ alg: "none", typ: "JWT" })}.${encode({ sub: userId, dev: "test-device", generation })}.test`;
|
|
818
|
+
}
|
|
819
|
+
function createTestAuthBinding(options) {
|
|
820
|
+
let generation = 0;
|
|
821
|
+
let state = options?.initialUserId ? {
|
|
822
|
+
state: "authenticated",
|
|
823
|
+
userId: options.initialUserId,
|
|
824
|
+
token: testJwt(options.initialUserId, generation)
|
|
825
|
+
} : { state: "loading" };
|
|
826
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
827
|
+
const notify = () => {
|
|
828
|
+
for (const listener of listeners) listener();
|
|
829
|
+
};
|
|
830
|
+
return {
|
|
831
|
+
get state() {
|
|
832
|
+
return state;
|
|
833
|
+
},
|
|
834
|
+
auth: async () => ({ token: state.state === "authenticated" ? state.token ?? "" : "" }),
|
|
835
|
+
resolveSyncState: async () => state,
|
|
836
|
+
resolveUserId: async () => state.state === "authenticated" ? state.userId : void 0,
|
|
837
|
+
resolveNodeId: async () => state.state === "authenticated" ? "test-device" : void 0,
|
|
838
|
+
subscribe(listener) {
|
|
839
|
+
listeners.add(listener);
|
|
840
|
+
return () => listeners.delete(listener);
|
|
841
|
+
},
|
|
842
|
+
setLoading() {
|
|
843
|
+
state = { state: "loading" };
|
|
844
|
+
notify();
|
|
845
|
+
},
|
|
846
|
+
signIn(userId, token) {
|
|
847
|
+
generation++;
|
|
848
|
+
state = { state: "authenticated", userId, token: token ?? testJwt(userId, generation) };
|
|
849
|
+
notify();
|
|
850
|
+
},
|
|
851
|
+
refreshToken(token) {
|
|
852
|
+
if (state.state !== "authenticated") throw new Error("Cannot refresh while signed out");
|
|
853
|
+
generation++;
|
|
854
|
+
state = {
|
|
855
|
+
...state,
|
|
856
|
+
token: token ?? testJwt(state.userId, generation)
|
|
857
|
+
};
|
|
858
|
+
notify();
|
|
859
|
+
},
|
|
860
|
+
signOut() {
|
|
861
|
+
state = options?.anonymous ? { state: "anonymous", mayConnectAnonymously: true } : { state: "signed-out", mayConnectAnonymously: false };
|
|
862
|
+
notify();
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// src/transport-metrics.ts
|
|
868
|
+
function wireBytes(message) {
|
|
869
|
+
return new TextEncoder().encode(JSON.stringify(message)).byteLength;
|
|
870
|
+
}
|
|
871
|
+
function createTransportMetrics() {
|
|
872
|
+
const metrics = {
|
|
873
|
+
connectionAttempts: 0,
|
|
874
|
+
disconnections: 0,
|
|
875
|
+
clientMessages: 0,
|
|
876
|
+
serverMessages: 0,
|
|
877
|
+
clientBytes: 0,
|
|
878
|
+
serverBytes: 0,
|
|
879
|
+
reset() {
|
|
880
|
+
this.connectionAttempts = 0;
|
|
881
|
+
this.disconnections = 0;
|
|
882
|
+
this.clientMessages = 0;
|
|
883
|
+
this.serverMessages = 0;
|
|
884
|
+
this.clientBytes = 0;
|
|
885
|
+
this.serverBytes = 0;
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
return {
|
|
889
|
+
metrics,
|
|
890
|
+
wrapTransport(pair) {
|
|
891
|
+
const client = {
|
|
892
|
+
...pair.client,
|
|
893
|
+
async connect(url, options) {
|
|
894
|
+
metrics.connectionAttempts++;
|
|
895
|
+
await pair.client.connect(url, options);
|
|
896
|
+
},
|
|
897
|
+
async disconnect() {
|
|
898
|
+
metrics.disconnections++;
|
|
899
|
+
await pair.client.disconnect();
|
|
900
|
+
},
|
|
901
|
+
send(message) {
|
|
902
|
+
metrics.clientMessages++;
|
|
903
|
+
metrics.clientBytes += wireBytes(message);
|
|
904
|
+
pair.client.send(message);
|
|
905
|
+
},
|
|
906
|
+
onMessage: (handler) => pair.client.onMessage(handler),
|
|
907
|
+
onClose: (handler) => pair.client.onClose(handler),
|
|
908
|
+
onError: (handler) => pair.client.onError(handler),
|
|
909
|
+
isConnected: () => pair.client.isConnected()
|
|
910
|
+
};
|
|
911
|
+
const serverTransport = {
|
|
912
|
+
...pair.serverTransport,
|
|
913
|
+
send(message) {
|
|
914
|
+
metrics.serverMessages++;
|
|
915
|
+
metrics.serverBytes += wireBytes(message);
|
|
916
|
+
pair.serverTransport.send(message);
|
|
917
|
+
},
|
|
918
|
+
onMessage: (handler) => pair.serverTransport.onMessage(handler),
|
|
919
|
+
onClose: (handler) => pair.serverTransport.onClose(handler),
|
|
920
|
+
onError: (handler) => pair.serverTransport.onError(handler),
|
|
921
|
+
isConnected: () => pair.serverTransport.isConnected(),
|
|
922
|
+
close: (code, reason) => pair.serverTransport.close(code, reason)
|
|
923
|
+
};
|
|
924
|
+
return { client, serverTransport };
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
export {
|
|
929
|
+
ChaosTransport2 as ChaosTransport,
|
|
930
|
+
TestDevice,
|
|
931
|
+
TestServer,
|
|
932
|
+
checkConvergence,
|
|
933
|
+
createMixedTestNetwork,
|
|
934
|
+
createTestAuthBinding,
|
|
935
|
+
createTestNetwork,
|
|
936
|
+
createTransportMetrics,
|
|
937
|
+
expectConverged,
|
|
938
|
+
expectConvergedEventually,
|
|
939
|
+
wrapTransportPairWithProtobufWire,
|
|
940
|
+
wrapTransportPairWithServerClock
|
|
941
|
+
};
|
|
942
|
+
//# sourceMappingURL=index.js.map
|