@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/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