@evolu/common 8.10.0 → 8.12.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 (152) hide show
  1. package/dist/src/Bytes.d.ts +39 -2
  2. package/dist/src/Bytes.d.ts.map +1 -1
  3. package/dist/src/Bytes.js +50 -2
  4. package/dist/src/Config.d.ts +22 -22
  5. package/dist/src/Config.d.ts.map +1 -1
  6. package/dist/src/Console.d.ts +62 -7
  7. package/dist/src/Console.d.ts.map +1 -1
  8. package/dist/src/Console.js +20 -4
  9. package/dist/src/Crypto.d.ts +76 -4
  10. package/dist/src/Crypto.d.ts.map +1 -1
  11. package/dist/src/Crypto.js +55 -4
  12. package/dist/src/Error.d.ts +45 -0
  13. package/dist/src/Error.d.ts.map +1 -1
  14. package/dist/src/Error.js +69 -0
  15. package/dist/src/Fs.d.ts +92 -18
  16. package/dist/src/Fs.d.ts.map +1 -1
  17. package/dist/src/Fs.js +2 -0
  18. package/dist/src/Identicon.d.ts +2 -2
  19. package/dist/src/Identicon.js +2 -2
  20. package/dist/src/LeakDetector.d.ts +22 -3
  21. package/dist/src/LeakDetector.d.ts.map +1 -1
  22. package/dist/src/LeakDetector.js +12 -2
  23. package/dist/src/LockManager.d.ts +8 -0
  24. package/dist/src/LockManager.d.ts.map +1 -1
  25. package/dist/src/LockManager.js +6 -0
  26. package/dist/src/Object.d.ts.map +1 -1
  27. package/dist/src/Object.js +5 -0
  28. package/dist/src/Platform.d.ts +47 -7
  29. package/dist/src/Platform.d.ts.map +1 -1
  30. package/dist/src/Platform.js +24 -5
  31. package/dist/src/Random.d.ts +25 -2
  32. package/dist/src/Random.d.ts.map +1 -1
  33. package/dist/src/Random.js +14 -2
  34. package/dist/src/Resource.d.ts +156 -1
  35. package/dist/src/Resource.d.ts.map +1 -1
  36. package/dist/src/Resource.js +201 -72
  37. package/dist/src/Schedule.d.ts +11 -10
  38. package/dist/src/Schedule.d.ts.map +1 -1
  39. package/dist/src/Schedule.js +1 -1
  40. package/dist/src/Sqlite.d.ts +132 -16
  41. package/dist/src/Sqlite.d.ts.map +1 -1
  42. package/dist/src/Sqlite.js +63 -9
  43. package/dist/src/Task.d.ts +15 -4
  44. package/dist/src/Task.d.ts.map +1 -1
  45. package/dist/src/Task.js +41 -15
  46. package/dist/src/Test.d.ts +9 -0
  47. package/dist/src/Test.d.ts.map +1 -1
  48. package/dist/src/Test.js +4 -0
  49. package/dist/src/Time.d.ts +106 -9
  50. package/dist/src/Time.d.ts.map +1 -1
  51. package/dist/src/Time.js +55 -4
  52. package/dist/src/Type.d.ts +1455 -1310
  53. package/dist/src/Type.d.ts.map +1 -1
  54. package/dist/src/Type.js +1274 -517
  55. package/dist/src/WebSocket.d.ts +164 -13
  56. package/dist/src/WebSocket.d.ts.map +1 -1
  57. package/dist/src/WebSocket.js +133 -24
  58. package/dist/src/Worker.d.ts +90 -8
  59. package/dist/src/Worker.d.ts.map +1 -1
  60. package/dist/src/Worker.js +28 -2
  61. package/dist/src/index.d.ts +6 -7
  62. package/dist/src/index.d.ts.map +1 -1
  63. package/dist/src/index.js +2 -3
  64. package/dist/src/local-first/Db.d.ts +52 -3
  65. package/dist/src/local-first/Db.d.ts.map +1 -1
  66. package/dist/src/local-first/Db.js +412 -137
  67. package/dist/src/local-first/Evolu.d.ts +412 -213
  68. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  69. package/dist/src/local-first/Evolu.js +181 -18
  70. package/dist/src/local-first/Owner.d.ts +13 -30
  71. package/dist/src/local-first/Owner.d.ts.map +1 -1
  72. package/dist/src/local-first/Owner.js +13 -30
  73. package/dist/src/local-first/Protocol.d.ts +106 -19
  74. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  75. package/dist/src/local-first/Protocol.js +162 -60
  76. package/dist/src/local-first/Query.d.ts +8 -15
  77. package/dist/src/local-first/Query.d.ts.map +1 -1
  78. package/dist/src/local-first/Relay.d.ts.map +1 -1
  79. package/dist/src/local-first/Relay.js +4 -2
  80. package/dist/src/local-first/Schema.d.ts +346 -23
  81. package/dist/src/local-first/Schema.d.ts.map +1 -1
  82. package/dist/src/local-first/Schema.js +214 -17
  83. package/dist/src/local-first/Shared.d.ts +537 -22
  84. package/dist/src/local-first/Shared.d.ts.map +1 -1
  85. package/dist/src/local-first/Shared.js +1437 -234
  86. package/dist/src/local-first/Storage.d.ts +195 -17
  87. package/dist/src/local-first/Storage.d.ts.map +1 -1
  88. package/dist/src/local-first/Storage.js +85 -22
  89. package/dist/src/local-first/Timestamp.d.ts +392 -41
  90. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  91. package/dist/src/local-first/Timestamp.js +403 -81
  92. package/dist/src/local-first/index.d.ts +0 -1
  93. package/dist/src/local-first/index.d.ts.map +1 -1
  94. package/dist/src/local-first/index.js +0 -1
  95. package/package.json +1 -1
  96. package/src/Assert.test.ts +2 -5
  97. package/src/Bytes.test.ts +27 -0
  98. package/src/Bytes.ts +58 -2
  99. package/src/Config.test.ts +2 -6
  100. package/src/Config.ts +133 -133
  101. package/src/Console.ts +62 -7
  102. package/src/Crypto.ts +76 -4
  103. package/src/Eq.test.ts +2 -3
  104. package/src/Error.test.ts +76 -3
  105. package/src/Error.ts +71 -0
  106. package/src/Fs.ts +92 -18
  107. package/src/Identicon.ts +2 -2
  108. package/src/LeakDetector.ts +22 -3
  109. package/src/LockManager.ts +8 -0
  110. package/src/Object.test.ts +27 -12
  111. package/src/Object.ts +5 -0
  112. package/src/Platform.ts +50 -8
  113. package/src/Random.ts +25 -2
  114. package/src/Resource.test.ts +837 -0
  115. package/src/Resource.ts +235 -15
  116. package/src/Schedule.test.ts +50 -12
  117. package/src/Schedule.ts +24 -14
  118. package/src/Sqlite.ts +137 -17
  119. package/src/Task.test.ts +189 -8
  120. package/src/Task.ts +56 -17
  121. package/src/Test.ts +9 -0
  122. package/src/Time.ts +106 -9
  123. package/src/Type.test.ts +946 -1028
  124. package/src/Type.ts +4195 -3136
  125. package/src/Types.test.ts +4 -14
  126. package/src/WebSocket.ts +313 -40
  127. package/src/Worker.ts +90 -8
  128. package/src/index.ts +20 -6
  129. package/src/local-first/Db.ts +644 -339
  130. package/src/local-first/Evolu.test.ts +994 -22
  131. package/src/local-first/Evolu.ts +625 -232
  132. package/src/local-first/Owner.ts +13 -30
  133. package/src/local-first/Protocol.test.ts +634 -10
  134. package/src/local-first/Protocol.ts +255 -109
  135. package/src/local-first/Query.ts +8 -15
  136. package/src/local-first/Relay.ts +4 -2
  137. package/src/local-first/Schema.test.ts +143 -0
  138. package/src/local-first/Schema.ts +376 -26
  139. package/src/local-first/Shared.test.ts +7731 -559
  140. package/src/local-first/Shared.ts +2036 -267
  141. package/src/local-first/Storage.ts +224 -36
  142. package/src/local-first/Timestamp.test.ts +344 -70
  143. package/src/local-first/Timestamp.ts +434 -118
  144. package/src/local-first/index.ts +0 -1
  145. package/dist/src/local-first/Error.d.ts +0 -12
  146. package/dist/src/local-first/Error.d.ts.map +0 -1
  147. package/dist/src/local-first/Error.js +0 -6
  148. package/dist/src/local-first/LocalAuth.d.ts +0 -150
  149. package/dist/src/local-first/LocalAuth.d.ts.map +0 -1
  150. package/dist/src/local-first/LocalAuth.js +0 -179
  151. package/src/local-first/Error.ts +0 -17
  152. package/src/local-first/LocalAuth.ts +0 -457
@@ -13,20 +13,41 @@ import {
13
13
  assertThrowsInstanceOf,
14
14
  assertTrue,
15
15
  } from "../Assert.ts";
16
+ import * as fc from "fast-check";
16
17
  import { describe, it } from "node:test";
17
18
  import type { Brand } from "../Brand.ts";
19
+ import { createBuffer } from "../Bytes.ts";
18
20
  import type { ConsoleEntry, TestConsole } from "../Console.ts";
19
21
  import { testCreateConsole } from "../Console.ts";
20
22
  import { constVoid } from "../Function.ts";
21
- import type { DbWorkerInit } from "./Db.ts";
23
+ import type { DbWorkerInit, UnsupportedDbVersionError } from "./Db.ts";
22
24
  import {
23
25
  AppName,
24
26
  createEvolu,
25
27
  createEvoluDeps,
28
+ type Evolu,
29
+ type EvoluConfig,
30
+ maxMutationSize,
26
31
  testAppName,
27
32
  type EvoluPlatformDeps,
28
33
  } from "./Evolu.ts";
29
- import { createOwnerWebSocketTransport, testAppOwner } from "./Owner.ts";
34
+ import {
35
+ createOwnerSecret,
36
+ createOwnerWebSocketTransport,
37
+ createSharedOwner,
38
+ testAppOwner,
39
+ } from "./Owner.ts";
40
+ import {
41
+ createProtocolBroadcastMessagesFromCrdtMessages,
42
+ createProtocolMessageBuffer,
43
+ createProtocolMessageFromCrdtMessages,
44
+ createTimestampsBuffer,
45
+ encodeAndEncryptDbChange,
46
+ encodeDbChange,
47
+ MessageType,
48
+ ProtocolErrorCode,
49
+ ProtocolMessageRangesMaxSize,
50
+ } from "./Protocol.ts";
30
51
  import { createQueryBuilder } from "./Schema.ts";
31
52
  import {
32
53
  consoleEntryOrErrorBroadcastChannelName,
@@ -36,23 +57,37 @@ import {
36
57
  type EvoluInput,
37
58
  type EvoluOutput,
38
59
  type SharedWorker,
60
+ type SharedWorkerId,
39
61
  type SharedWorkerInput,
40
62
  type SharedWorkerOutput,
63
+ type SyncState,
41
64
  } from "./Shared.ts";
42
- import { testCreateLockManager } from "../LockManager.ts";
65
+ import {
66
+ acquireLeaderLock,
67
+ type LockManagerDep,
68
+ testCreateLockManager,
69
+ } from "../LockManager.ts";
43
70
  import { installPolyfills } from "../Polyfills.ts";
44
71
  import { err, ok } from "../Result.ts";
45
72
  import { SqliteBoolean } from "../Sqlite.ts";
46
- import { explicitAbortReason, testCreateRun } from "../Task.ts";
73
+ import { explicitAbortReason, testCreateDeps, testCreateRun } from "../Task.ts";
74
+ import { testCreateId } from "../Test.ts";
47
75
  import {
48
76
  assertType,
49
77
  createIdFromString,
78
+ FiniteNumber,
50
79
  id,
51
80
  NonEmptyTrimmedString100,
52
81
  nullOr,
82
+ PositiveInt,
83
+ String as StringType,
53
84
  testName,
85
+ Name,
86
+ Uint8Array as Uint8ArrayType,
54
87
  } from "../Type.ts";
55
88
  import type { ExtractTyped } from "../Type.ts";
89
+ import { DbChange } from "./Storage.ts";
90
+ import { createTimestamp, type NodeId } from "./Timestamp.ts";
56
91
  import {
57
92
  testCreateBroadcastChannel,
58
93
  testCreateMessageChannel,
@@ -203,6 +238,8 @@ describe("Evolu", () => {
203
238
  }),
204
239
  );
205
240
 
241
+ assertThrowsInstanceOf(() => AppName.orThrow("a".repeat(42)), Error);
242
+
206
243
  const appName = AppName.orThrow("my-app");
207
244
  assertType<typeof appName, AppName>();
208
245
  assertType<typeof AppName.Input, string>();
@@ -212,12 +249,22 @@ describe("Evolu", () => {
212
249
  describe("createEvoluDeps", () => {
213
250
  const setupCreateEvoluDeps = async (
214
251
  console: TestConsole = testCreateConsole(),
252
+ lockManager: LockManagerDep["lockManager"] = testCreateLockManager(),
215
253
  ): Promise<{
216
254
  readonly deps: ReturnType<typeof createEvoluDeps>;
217
255
  readonly messages: Array<SharedWorkerInput>;
256
+ readonly sharedWorkerPort: MessagePort<
257
+ SharedWorkerOutput,
258
+ SharedWorkerInput
259
+ >;
218
260
  readonly consoleEntryOrErrorBroadcastChannel: {
219
261
  readonly postMessage: (message: ConsoleEntryOrError) => void;
220
262
  };
263
+ /**
264
+ * Sends the worker's Connected message, as a worker holding its lock
265
+ * does.
266
+ */
267
+ readonly connect: (workerId: SharedWorkerId) => Promise<void>;
221
268
  readonly [Symbol.dispose]: () => void;
222
269
  }> => {
223
270
  using disposer = new DisposableStack();
@@ -226,7 +273,11 @@ describe("Evolu", () => {
226
273
  );
227
274
 
228
275
  const messages: Array<SharedWorkerInput> = [];
276
+ const sharedWorkerPort: {
277
+ value: MessagePort<SharedWorkerOutput, SharedWorkerInput> | null;
278
+ } = { value: null };
229
279
  worker.self.onConnect = (port) => {
280
+ sharedWorkerPort.value = port;
230
281
  port.onMessage = (message) => {
231
282
  messages.push(message);
232
283
  };
@@ -238,7 +289,7 @@ describe("Evolu", () => {
238
289
  createDbWorker: testCreateWorker,
239
290
  createBroadcastChannel: testCreateBroadcastChannel,
240
291
  createMessageChannel: testCreateMessageChannel,
241
- lockManager: testCreateLockManager(),
292
+ lockManager,
242
293
  sharedWorker: worker,
243
294
  reloadApp: constVoid,
244
295
  console,
@@ -247,8 +298,10 @@ describe("Evolu", () => {
247
298
 
248
299
  await testWaitForWorkerMessage();
249
300
 
250
- assertLength(messages, 1);
251
- assertSame(messages[0].type, "AnnounceTabLeader");
301
+ // A tab says nothing until its worker connects.
302
+ assertEqual(messages, []);
303
+ const port = sharedWorkerPort.value;
304
+ assertNotNull(port);
252
305
  const consoleEntryOrErrorBroadcastChannel = disposer.use(
253
306
  testCreateBroadcastChannel<ConsoleEntryOrError>(
254
307
  consoleEntryOrErrorBroadcastChannelName,
@@ -259,21 +312,110 @@ describe("Evolu", () => {
259
312
  return {
260
313
  deps,
261
314
  messages,
315
+ sharedWorkerPort: port,
262
316
  consoleEntryOrErrorBroadcastChannel,
317
+ connect: async (workerId) => {
318
+ port.postMessage({
319
+ type: "Connected",
320
+ workerId,
321
+ syncStateChannelName: `evolu:sync-state:${workerId}`,
322
+ });
323
+ await testWaitForWorkerMessage();
324
+ },
263
325
  [Symbol.dispose]: () => disposables.dispose(),
264
326
  };
265
327
  };
266
328
 
267
- it("posts AnnounceTabLeader with console level to worker", async () => {
329
+ it("reports an OtherBuildRunningError its worker sends and clears it once the worker connects", async () => {
330
+ using setup = await setupCreateEvoluDeps();
331
+
332
+ setup.sharedWorkerPort.postMessage({
333
+ type: "Error",
334
+ error: { type: "OtherBuildRunningError" },
335
+ });
336
+ await testWaitForWorkerMessage();
337
+ assertEqual(setup.deps.evoluError.get(), {
338
+ type: "OtherBuildRunningError",
339
+ });
340
+
341
+ await setup.connect(testCreateId()<"SharedWorker">());
342
+ assertSame(setup.deps.evoluError.get(), null);
343
+ });
344
+
345
+ it("keeps another error when its worker connects", async () => {
346
+ using setup = await setupCreateEvoluDeps();
347
+ const error: UnsupportedDbVersionError = {
348
+ type: "UnsupportedDbVersionError",
349
+ storedVersion: PositiveInt.orThrow(3),
350
+ supportedVersion: PositiveInt.orThrow(2),
351
+ };
352
+
353
+ setup.sharedWorkerPort.postMessage({ type: "Error", error });
354
+ await testWaitForWorkerMessage();
355
+ await setup.connect(testCreateId()<"SharedWorker">());
356
+
357
+ assertEqual(setup.deps.evoluError.get(), error);
358
+ });
359
+
360
+ it("says nothing to a worker that reports it waits", async () => {
361
+ using setup = await setupCreateEvoluDeps();
362
+
363
+ setup.sharedWorkerPort.postMessage({
364
+ type: "Waiting",
365
+ workerId: testCreateId()<"SharedWorker">(),
366
+ });
367
+ await testWaitForWorkerMessage();
368
+
369
+ assertEqual(setup.messages, []);
370
+ assertSame(setup.deps.evoluError.get(), null);
371
+ });
372
+
373
+ it("says nothing to a worker that reports it keeps databases in memory", async () => {
374
+ using setup = await setupCreateEvoluDeps();
375
+
376
+ setup.sharedWorkerPort.postMessage({ type: "StorageUnavailable" });
377
+ await testWaitForWorkerMessage();
378
+
379
+ assertEqual(setup.messages, []);
380
+ assertSame(setup.deps.evoluError.get(), null);
381
+ });
382
+
383
+ it("announces itself as tab leader with its console level once its worker connects", async () => {
268
384
  const testConsole = testCreateConsole();
269
385
  using setup = await setupCreateEvoluDeps(testConsole);
270
- const { messages } = setup;
386
+ const { messages, connect } = setup;
271
387
 
272
- assertLength(messages, 1);
273
- assertEqual(messages[0], {
274
- type: "AnnounceTabLeader",
275
- consoleLevel: testConsole.getLevel(),
276
- });
388
+ await connect(testCreateId()<"SharedWorker">());
389
+
390
+ assertEqual(messages, [
391
+ { type: "RequestSyncState" },
392
+ { type: "AnnounceTabLeader", consoleLevel: testConsole.getLevel() },
393
+ ]);
394
+ });
395
+
396
+ it("competes for tab leadership only with tabs of its worker", async () => {
397
+ const lockManager = testCreateLockManager();
398
+ await using run = testCreateRun({ lockManager });
399
+ const createWorkerId = testCreateId();
400
+ const worker = createWorkerId<"SharedWorker">();
401
+ // The tab's worker holds this lock for its lifetime.
402
+ await using _workerBuildLock = await run.ok(acquireLeaderLock("tab"));
403
+ using first = await setupCreateEvoluDeps(undefined, lockManager);
404
+ using second = await setupCreateEvoluDeps(undefined, lockManager);
405
+ using ofOtherWorker = await setupCreateEvoluDeps(undefined, lockManager);
406
+ await first.connect(worker);
407
+ await second.connect(worker);
408
+ await ofOtherWorker.connect(createWorkerId<"SharedWorker">());
409
+ const isLeader = (setup: typeof first): boolean =>
410
+ setup.messages.some((message) => message.type === "AnnounceTabLeader");
411
+
412
+ assertTrue(isLeader(first));
413
+ assertFalse(isLeader(second));
414
+ assertTrue(isLeader(ofOtherWorker));
415
+
416
+ first[Symbol.dispose]();
417
+ await testWaitForWorkerMessage();
418
+ assertTrue(isLeader(second));
277
419
  });
278
420
 
279
421
  it("initializes db worker from shared worker init output", async () => {
@@ -367,6 +509,11 @@ describe("Evolu", () => {
367
509
  port.onMessage = (message) => {
368
510
  messages.push(message);
369
511
  };
512
+ port.postMessage({
513
+ type: "Connected",
514
+ workerId: testCreateId()<"SharedWorker">(),
515
+ syncStateChannelName: "evolu:sync-state:worker",
516
+ });
370
517
  };
371
518
  worker.connect();
372
519
 
@@ -382,8 +529,11 @@ describe("Evolu", () => {
382
529
  await testWaitForWorkerMessage();
383
530
 
384
531
  assertNotUndefined(deps.console);
385
- assertLength(messages, 1);
386
- assertSame(messages[0].type, "AnnounceTabLeader");
532
+ const messageTypes = ["RequestSyncState", "AnnounceTabLeader"];
533
+ assertEqual(
534
+ messages.map((message) => message.type),
535
+ messageTypes,
536
+ );
387
537
  using consoleEntryOrErrorBroadcastChannel =
388
538
  testCreateBroadcastChannel<ConsoleEntryOrError>(
389
539
  consoleEntryOrErrorBroadcastChannelName,
@@ -396,8 +546,10 @@ describe("Evolu", () => {
396
546
 
397
547
  await testWaitForWorkerMessage();
398
548
 
399
- assertLength(messages, 1);
400
- assertSame(messages[0].type, "AnnounceTabLeader");
549
+ assertEqual(
550
+ messages.map((message) => message.type),
551
+ messageTypes,
552
+ );
401
553
  assertEqual(deps.evoluError.get(), {
402
554
  type: "UnknownError",
403
555
  error: ["boom"],
@@ -428,6 +580,84 @@ describe("Evolu", () => {
428
580
  assertEqual(testConsole.getEntriesSnapshot(), [entry]);
429
581
  });
430
582
 
583
+ it("follows the sync state channel its worker names", async () => {
584
+ using setup = await setupCreateEvoluDeps();
585
+ const { deps, messages, connect } = setup;
586
+ const workerId = testCreateId()<"SharedWorker">();
587
+ using channel = testCreateBroadcastChannel<SyncState>(
588
+ `evolu:sync-state:${workerId}`,
589
+ );
590
+ using otherChannel = testCreateBroadcastChannel<SyncState>(
591
+ "evolu:sync-state:other-worker",
592
+ );
593
+ const createState = (label: string): SyncState => ({
594
+ transports: [],
595
+ tenants: [{ name: Name.orThrow(label), refused: false, owners: [] }],
596
+ });
597
+
598
+ // Nothing reaches the tab before its worker names the channel.
599
+ channel.postMessage(createState("early"));
600
+ await testWaitForWorkerMessage();
601
+ assertSame(deps.syncState.get(), null);
602
+
603
+ await connect(workerId);
604
+ assertEqual(messages.at(0), { type: "RequestSyncState" });
605
+ channel.postMessage(createState("requested"));
606
+ await testWaitForWorkerMessage();
607
+ assertEqual(deps.syncState.get(), createState("requested"));
608
+
609
+ channel.postMessage(createState("changed"));
610
+ await testWaitForWorkerMessage();
611
+ assertEqual(deps.syncState.get(), createState("changed"));
612
+
613
+ // Another worker's channel is never heard.
614
+ otherChannel.postMessage(createState("other"));
615
+ await testWaitForWorkerMessage();
616
+ assertEqual(deps.syncState.get(), createState("changed"));
617
+ });
618
+
619
+ it("listens on its worker's channel before asking for a snapshot", async () => {
620
+ const name = "evolu:sync-state:worker";
621
+ using worker = testCreateSharedWorker<
622
+ SharedWorkerInput,
623
+ SharedWorkerOutput
624
+ >();
625
+ worker.self.onConnect = (port) => {
626
+ port.onMessage = constVoid;
627
+ port.postMessage({
628
+ type: "Connected",
629
+ workerId: testCreateId()<"SharedWorker">(),
630
+ syncStateChannelName: name,
631
+ });
632
+ };
633
+ worker.connect();
634
+ using channel = testCreateBroadcastChannel<SyncState>(name);
635
+ const requested: SyncState = { transports: [], tenants: [] };
636
+ // A worker on another thread can broadcast its answer before the tab's
637
+ // postMessage returns.
638
+ const port = new Proxy(worker.port, {
639
+ get: (target, property) =>
640
+ property === "postMessage"
641
+ ? (message: SharedWorkerInput) => {
642
+ if (message.type === "RequestSyncState")
643
+ channel.postMessage(requested);
644
+ target.postMessage(message);
645
+ }
646
+ : (Reflect.get(target, property) as unknown),
647
+ });
648
+
649
+ using deps = createEvoluDeps({
650
+ createDbWorker: testCreateWorker,
651
+ createBroadcastChannel: testCreateBroadcastChannel,
652
+ createMessageChannel: testCreateMessageChannel,
653
+ lockManager: testCreateLockManager(),
654
+ sharedWorker: { port, [Symbol.dispose]: constVoid },
655
+ reloadApp: constVoid,
656
+ });
657
+ await testWaitForWorkerMessage();
658
+ assertEqual(deps.syncState.get(), requested);
659
+ });
660
+
431
661
  it("maps ConsoleEntry error output to deps.evoluError store", async () => {
432
662
  using setup = await setupCreateEvoluDeps();
433
663
  const { deps, consoleEntryOrErrorBroadcastChannel } = setup;
@@ -497,6 +727,78 @@ describe("Evolu", () => {
497
727
  assertEqual(deps.evoluError.get(), error);
498
728
  });
499
729
 
730
+ for (const source of ["SharedWorker", "Error", "ConsoleEntry"] as const) {
731
+ it(`preserves the first startup refusal while logging a later ${source} message`, async () => {
732
+ const testConsole = testCreateConsole();
733
+ using setup = await setupCreateEvoluDeps(testConsole);
734
+ const { deps, sharedWorkerPort, consoleEntryOrErrorBroadcastChannel } =
735
+ setup;
736
+ assertSame(deps.evoluError.get(), null);
737
+
738
+ const initialError = {
739
+ type: "UnknownError",
740
+ error: "before refusal",
741
+ } as const;
742
+ consoleEntryOrErrorBroadcastChannel.postMessage({
743
+ type: "Error",
744
+ error: initialError,
745
+ });
746
+ await testWaitForWorkerMessage();
747
+ assertEqual(deps.evoluError.get(), initialError);
748
+
749
+ let notifications = 0;
750
+ deps.evoluError.subscribe(() => {
751
+ notifications++;
752
+ });
753
+ const refusal: UnsupportedDbVersionError = {
754
+ type: "UnsupportedDbVersionError",
755
+ storedVersion: PositiveInt.orThrow(2),
756
+ supportedVersion: PositiveInt.orThrow(1),
757
+ };
758
+ sharedWorkerPort.postMessage({ type: "Error", error: refusal });
759
+ await testWaitForWorkerMessage();
760
+ const storedRefusal = deps.evoluError.get();
761
+ assertEqual(storedRefusal, refusal);
762
+ assertSame(notifications, 1);
763
+
764
+ const laterRefusal: UnsupportedDbVersionError = {
765
+ ...refusal,
766
+ storedVersion: PositiveInt.orThrow(3),
767
+ };
768
+ const laterError = {
769
+ type: "UnknownError",
770
+ error: "after refusal",
771
+ } as const;
772
+ const laterEntry: ConsoleEntry = {
773
+ method: "error",
774
+ path: source === "ConsoleEntry" ? ["worker"] : [],
775
+ args: [source === "SharedWorker" ? laterRefusal : laterError],
776
+ };
777
+ if (source === "SharedWorker") {
778
+ sharedWorkerPort.postMessage({ type: "Error", error: laterRefusal });
779
+ } else if (source === "Error") {
780
+ consoleEntryOrErrorBroadcastChannel.postMessage({
781
+ type: "Error",
782
+ error: laterError,
783
+ });
784
+ } else {
785
+ consoleEntryOrErrorBroadcastChannel.postMessage({
786
+ type: "ConsoleEntry",
787
+ entry: laterEntry,
788
+ });
789
+ }
790
+ await testWaitForWorkerMessage();
791
+
792
+ assertSame(deps.evoluError.get(), storedRefusal);
793
+ assertSame(notifications, 1);
794
+ assertEqual(testConsole.getEntriesSnapshot(), [
795
+ { method: "error", path: [], args: [initialError] },
796
+ { method: "error", path: [], args: [refusal] },
797
+ laterEntry,
798
+ ]);
799
+ });
800
+ }
801
+
500
802
  it("throws for unknown tab output type", () => {
501
803
  const consoleEntryOrErrorBroadcastChannel: {
502
804
  value: {
@@ -530,13 +832,18 @@ describe("Evolu", () => {
530
832
  }, Error);
531
833
  });
532
834
 
533
- it("dispose cleans up resources", () => {
835
+ it("dispose cleans up resources", async () => {
534
836
  const worker = testCreateSharedWorker<
535
837
  SharedWorkerInput,
536
838
  SharedWorkerOutput
537
839
  >();
538
840
  worker.self.onConnect = (port) => {
539
841
  port.onMessage = constVoid;
842
+ port.postMessage({
843
+ type: "Connected",
844
+ workerId: testCreateId()<"SharedWorker">(),
845
+ syncStateChannelName: "evolu:sync-state:worker",
846
+ });
540
847
  };
541
848
  worker.connect();
542
849
 
@@ -564,16 +871,93 @@ describe("Evolu", () => {
564
871
  reloadApp: constVoid,
565
872
  });
566
873
 
567
- assertLength(broadcastChannels, 1);
874
+ // The console/error channel, then the sync state channel its worker names.
875
+ assertSame(broadcastChannels.length, 1);
876
+ await testWaitForWorkerMessage();
877
+ assertLength(broadcastChannels, 2);
568
878
  assertFalse(broadcastChannels[0].isDisposed());
879
+ assertFalse(broadcastChannels[1].isDisposed());
569
880
  assertFalse(workerDisposed);
570
881
  deps[Symbol.dispose]();
571
882
  assertTrue(broadcastChannels[0].isDisposed());
883
+ assertTrue(broadcastChannels[1].isDisposed());
572
884
  assertTrue(workerDisposed);
573
885
  });
574
886
  });
575
887
 
888
+ describe("local tables", () => {
889
+ const LocalSchema = { _todo: Schema.todo };
890
+ const config: EvoluConfig = {
891
+ appName: testAppName,
892
+ appOwner: testAppOwner,
893
+ transports: [],
894
+ };
895
+
896
+ it("keeps useOwner available for an instance with only local tables", async () => {
897
+ const messages: Array<SharedWorkerInput> = [];
898
+ await using setup = await setupRunWithEvoluDeps({
899
+ onSharedWorkerPostMessage: (message) => {
900
+ messages.push(message);
901
+ },
902
+ });
903
+ const evolu = await setup.run.ok(createEvolu(LocalSchema, config));
904
+ assertType<typeof evolu, Evolu<typeof LocalSchema>>();
905
+ assertSame(evolu.appOwner, testAppOwner);
906
+ assertEqual(evolu.name, `AppName-${createIdFromString(testAppOwner.id)}`);
907
+ await testWaitForWorkerMessage();
908
+ assertEqual(setup.evoluInputs, []);
909
+ const message = messages.find(
910
+ (message) => message.type === "CreateEvolu",
911
+ );
912
+ assertNotUndefined(message);
913
+ assertSame(message.encryptionKey, testAppOwner.encryptionKey);
914
+ assertFalse(message.memoryOnly);
915
+
916
+ evolu.insert("_todo", {
917
+ title: NonEmptyTrimmedString100.orThrow("Local"),
918
+ });
919
+ await testWaitForWorkerMessage();
920
+ assertTrue(setup.evoluInputs.some((input) => input.type === "Mutate"));
921
+ assertFalse(setup.evoluInputs.some((input) => input.type === "UseOwner"));
922
+ const unuseOwner = evolu.useOwner(testAppOwner, [testOwnerTransport]);
923
+ await testWaitForWorkerMessage();
924
+ assertTrue(setup.evoluInputs.some((input) => input.type === "UseOwner"));
925
+ unuseOwner();
926
+ await evolu[Symbol.asyncDispose]();
927
+ assertThrowsInstanceOf(
928
+ () =>
929
+ evolu.insert("_todo", {
930
+ title: NonEmptyTrimmedString100.orThrow("Disposed"),
931
+ }),
932
+ Error,
933
+ );
934
+ });
935
+ });
936
+
576
937
  describe("createEvolu", () => {
938
+ it("uses the default relay when transports are omitted", async () => {
939
+ await using setup = await setupRunWithEvoluDeps();
940
+ await setup.run.ok(
941
+ createEvolu(Schema, {
942
+ appName: testAppName,
943
+ appOwner: testAppOwner,
944
+ }),
945
+ );
946
+ await testWaitForWorkerMessage();
947
+ const input = setup.evoluInputs.find(
948
+ (input) => input.type === "UseOwner",
949
+ );
950
+ assertNotUndefined(input);
951
+ assertEqual(input.actions, [
952
+ {
953
+ action: "add",
954
+ owner: {
955
+ owner: testAppOwner,
956
+ transports: [{ type: "WebSocket", url: "wss://free.evoluhq.com" }],
957
+ },
958
+ },
959
+ ]);
960
+ });
577
961
  it("resolves name from appName and appOwner hash", async () => {
578
962
  await using setup = await setupRunWithEvoluDeps();
579
963
  const { run } = setup;
@@ -644,6 +1028,70 @@ describe("Evolu", () => {
644
1028
  assertNonNullable(message.evoluPort);
645
1029
  });
646
1030
 
1031
+ describe("requestSync", () => {
1032
+ it("preserves pending owner operations and returns immediately", async () => {
1033
+ await using setup = await setupRunWithEvoluDeps();
1034
+ const { run, evoluInputs } = setup;
1035
+ const evolu = await run.ok(testCreateEvolu);
1036
+ const sharedOwner = createSharedOwner(createOwnerSecret(run.deps));
1037
+ const owner = { owner: sharedOwner, transports: [testOwnerTransport] };
1038
+ assertNotSame(sharedOwner.id, evolu.appOwner.id);
1039
+
1040
+ const unuse = evolu.useOwner(sharedOwner, [testOwnerTransport]);
1041
+ const result = evolu.requestSync(sharedOwner.id);
1042
+ assertType<typeof result, void>();
1043
+ assertSame(result, undefined);
1044
+ unuse();
1045
+ evolu.requestSync(sharedOwner.id);
1046
+ assertEqual(evoluInputs, []);
1047
+
1048
+ await testWaitForWorkerMessage();
1049
+ assertEqual(evoluInputs, [
1050
+ {
1051
+ type: "UseOwner",
1052
+ actions: [{ action: "add", owner }],
1053
+ },
1054
+ {
1055
+ type: "UseOwner",
1056
+ actions: [
1057
+ { action: "sync", ownerId: sharedOwner.id },
1058
+ { action: "remove", owner },
1059
+ ],
1060
+ },
1061
+ {
1062
+ type: "UseOwner",
1063
+ actions: [{ action: "sync", ownerId: sharedOwner.id }],
1064
+ },
1065
+ ]);
1066
+ });
1067
+
1068
+ it("drops pending requests on disposal", async () => {
1069
+ await using setup = await setupRunWithEvoluDeps();
1070
+ const { run, evoluInputs } = setup;
1071
+ const evolu = await run.ok(testCreateEvolu);
1072
+ evolu.requestSync(testAppOwner.id);
1073
+ await evolu[Symbol.asyncDispose]();
1074
+ await testWaitForWorkerMessage();
1075
+ assertEqual(evoluInputs, []);
1076
+ });
1077
+
1078
+ it("preserves registration, mutation, and sync request order", async () => {
1079
+ await using setup = await setupRunWithEvoluDeps();
1080
+ const { run, evoluInputs } = setup;
1081
+ const evolu = await run.ok(testCreateEvolu);
1082
+ evolu.useOwner(testAppOwner, [testOwnerTransport]);
1083
+ evolu.insert("todo", {
1084
+ title: NonEmptyTrimmedString100.orThrow("Before sync"),
1085
+ });
1086
+ evolu.requestSync(testAppOwner.id);
1087
+ await testWaitForWorkerMessage();
1088
+ assertEqual(
1089
+ evoluInputs.map(({ type }) => type),
1090
+ ["UseOwner", "Mutate", "UseOwner"],
1091
+ );
1092
+ });
1093
+ });
1094
+
647
1095
  describe("useOwner", () => {
648
1096
  it("auto-uses appOwner in a microtask when transports are configured", async () => {
649
1097
  await using setup = await setupRunWithEvoluDeps();
@@ -898,7 +1346,7 @@ describe("Evolu", () => {
898
1346
  assertEqual(evoluInputs, [{ type: "Export" }]);
899
1347
  });
900
1348
 
901
- it("throws from sync methods after dispose", async () => {
1349
+ it("throws from synchronous methods after disposal", async () => {
902
1350
  await using setup = await setupRunWithEvoluDeps();
903
1351
  const { run } = setup;
904
1352
  const evolu = await run.ok(testCreateEvolu);
@@ -992,6 +1440,13 @@ describe("Evolu", () => {
992
1440
  }, Error).message,
993
1441
  disposedMessage,
994
1442
  );
1443
+
1444
+ assertEqual(
1445
+ assertThrowsInstanceOf(() => {
1446
+ evolu.requestSync(testAppOwner.id);
1447
+ }, Error).message,
1448
+ disposedMessage,
1449
+ );
995
1450
  });
996
1451
 
997
1452
  it("delete methods are placeholders", async () => {
@@ -1315,6 +1770,229 @@ describe("Evolu", () => {
1315
1770
  });
1316
1771
 
1317
1772
  describe("query behavior", () => {
1773
+ it("subscribeQuery catches a refresh between loading and subscribing", async () => {
1774
+ await using setup = await setupRunWithEvoluDeps();
1775
+ const { run, evoluInputs, postEvoluOutput } = setup;
1776
+ const evolu = await run.ok(testCreateEvolu);
1777
+
1778
+ const initialLoad = evolu.loadQuery(todoTitleQuery);
1779
+ await testWaitForWorkerMessage();
1780
+ postEvoluOutput({
1781
+ type: "OnPatchesByQuery",
1782
+ patchesByQuery: new Map([
1783
+ [todoTitleQuery, [{ op: "replaceAll", value: [] }]],
1784
+ ]),
1785
+ onCompleteIds: [],
1786
+ });
1787
+ const initialRows = await initialLoad;
1788
+ assertEqual(initialRows, []);
1789
+
1790
+ evoluInputs.length = 0;
1791
+ postEvoluOutput({ type: "RefreshQueries" });
1792
+ await testWaitForWorkerMessage();
1793
+ assertEqual(evoluInputs, []);
1794
+
1795
+ let notifications = 0;
1796
+ const unsubscribe = evolu.subscribeQuery(todoTitleQuery)(() => {
1797
+ notifications += 1;
1798
+ });
1799
+ // React use() must still see fulfilled rows if another render happens
1800
+ // before the worker answers the refresh queued by subscription.
1801
+ const cachedLoad = evolu.loadQuery(todoTitleQuery);
1802
+ assert("status" in cachedLoad, "Cached promises expose React status");
1803
+ assertSame(cachedLoad.status, "fulfilled");
1804
+ assert("value" in cachedLoad, "Fulfilled promises expose cached rows");
1805
+ assertSame(cachedLoad.value, initialRows);
1806
+ assertSame(await cachedLoad, initialRows);
1807
+ assertSame(notifications, 0);
1808
+ await testWaitForWorkerMessage();
1809
+ assertEqual(evoluInputs, [
1810
+ { type: "Query", queries: new Set([todoTitleQuery]) },
1811
+ ]);
1812
+
1813
+ postEvoluOutput({
1814
+ type: "OnPatchesByQuery",
1815
+ patchesByQuery: new Map([
1816
+ [
1817
+ todoTitleQuery,
1818
+ [{ op: "replaceAll", value: [{ title: "Synced" }] }],
1819
+ ],
1820
+ ]),
1821
+ onCompleteIds: [],
1822
+ });
1823
+ await testWaitForWorkerMessage();
1824
+ assertEqual(evolu.getQueryRows(todoTitleQuery), [{ title: "Synced" }]);
1825
+ assertEqual(notifications, 1);
1826
+ // React use() sees a new fulfilled promise after the refresh.
1827
+ const refreshedLoad = evolu.loadQuery(todoTitleQuery);
1828
+ assertNotSame(refreshedLoad, cachedLoad);
1829
+ assertEqual(await refreshedLoad, [{ title: "Synced" }]);
1830
+ unsubscribe();
1831
+ });
1832
+
1833
+ it("subscribeQuery catches a mutation while the initial read is pending", async () => {
1834
+ await using setup = await setupRunWithEvoluDeps();
1835
+ const { run, evoluInputs, postEvoluOutput } = setup;
1836
+ const evolu = await run.ok(testCreateEvolu);
1837
+
1838
+ const initialLoad = evolu.loadQuery(todoTitleQuery);
1839
+ await testWaitForWorkerMessage();
1840
+ evolu.insert("todo", { title: NonEmptyTrimmedString100.orThrow("New") });
1841
+ await testWaitForWorkerMessage();
1842
+ evoluInputs.length = 0;
1843
+
1844
+ const unsubscribe = evolu.subscribeQuery(todoTitleQuery)(constVoid);
1845
+ assertSame(evolu.loadQuery(todoTitleQuery), initialLoad);
1846
+ await testWaitForWorkerMessage();
1847
+ assertEqual(evoluInputs, [
1848
+ { type: "Query", queries: new Set([todoTitleQuery]) },
1849
+ ]);
1850
+
1851
+ // The old read completes first. Its promise must still settle, and the
1852
+ // new subscription must retain the loading entry for the fresh response.
1853
+ postEvoluOutput({
1854
+ type: "OnPatchesByQuery",
1855
+ patchesByQuery: new Map([
1856
+ [todoTitleQuery, [{ op: "replaceAll", value: [] }]],
1857
+ ]),
1858
+ onCompleteIds: [],
1859
+ });
1860
+ assertEqual(await initialLoad, []);
1861
+ assertSame(evolu.loadQuery(todoTitleQuery), initialLoad);
1862
+
1863
+ postEvoluOutput({
1864
+ type: "OnPatchesByQuery",
1865
+ patchesByQuery: new Map([
1866
+ [todoTitleQuery, [{ op: "replaceAll", value: [{ title: "New" }] }]],
1867
+ ]),
1868
+ onCompleteIds: [],
1869
+ });
1870
+ await testWaitForWorkerMessage();
1871
+ assertEqual(evolu.getQueryRows(todoTitleQuery), [{ title: "New" }]);
1872
+ assertEqual(await evolu.loadQuery(todoTitleQuery), [{ title: "New" }]);
1873
+ unsubscribe();
1874
+ });
1875
+
1876
+ it("subscribeQuery reuses valid pending and completed reads", async () => {
1877
+ await using setup = await setupRunWithEvoluDeps();
1878
+ const { run, evoluInputs, postEvoluOutput } = setup;
1879
+ const evolu = await run.ok(testCreateEvolu);
1880
+
1881
+ const initialLoad = evolu.loadQuery(todoTitleQuery);
1882
+ await testWaitForWorkerMessage();
1883
+ evoluInputs.length = 0;
1884
+ const unsubscribe = evolu.subscribeQuery(todoTitleQuery)(constVoid);
1885
+ assertSame(evolu.loadQuery(todoTitleQuery), initialLoad);
1886
+ await testWaitForWorkerMessage();
1887
+ assertEqual(evoluInputs, []);
1888
+
1889
+ postEvoluOutput({
1890
+ type: "OnPatchesByQuery",
1891
+ patchesByQuery: new Map([
1892
+ [todoTitleQuery, [{ op: "replaceAll", value: [] }]],
1893
+ ]),
1894
+ onCompleteIds: [],
1895
+ });
1896
+ await initialLoad;
1897
+ unsubscribe();
1898
+
1899
+ const unsubscribeAgain = evolu.subscribeQuery(todoTitleQuery)(constVoid);
1900
+ assertSame(evolu.loadQuery(todoTitleQuery), initialLoad);
1901
+ await testWaitForWorkerMessage();
1902
+ assertEqual(evoluInputs, []);
1903
+ unsubscribeAgain();
1904
+ });
1905
+
1906
+ it("subscribeQuery keeps refreshed rows cached after unsubscribe until the next invalidation", async () => {
1907
+ await using setup = await setupRunWithEvoluDeps();
1908
+ const { run, evoluInputs, postEvoluOutput } = setup;
1909
+ const evolu = await run.ok(testCreateEvolu);
1910
+
1911
+ const initialLoad = evolu.loadQuery(todoTitleQuery);
1912
+ await testWaitForWorkerMessage();
1913
+ postEvoluOutput({
1914
+ type: "OnPatchesByQuery",
1915
+ patchesByQuery: new Map([
1916
+ [todoTitleQuery, [{ op: "replaceAll", value: [] }]],
1917
+ ]),
1918
+ onCompleteIds: [],
1919
+ });
1920
+ await initialLoad;
1921
+ postEvoluOutput({ type: "RefreshQueries" });
1922
+ await testWaitForWorkerMessage();
1923
+ evoluInputs.length = 0;
1924
+
1925
+ const unsubscribe = evolu.subscribeQuery(todoTitleQuery)(constVoid);
1926
+ await testWaitForWorkerMessage();
1927
+ assertEqual(evoluInputs, [
1928
+ { type: "Query", queries: new Set([todoTitleQuery]) },
1929
+ ]);
1930
+ unsubscribe();
1931
+ evoluInputs.length = 0;
1932
+
1933
+ // The refresh response still replaces the cached rows.
1934
+ postEvoluOutput({
1935
+ type: "OnPatchesByQuery",
1936
+ patchesByQuery: new Map([
1937
+ [
1938
+ todoTitleQuery,
1939
+ [{ op: "replaceAll", value: [{ title: "Synced" }] }],
1940
+ ],
1941
+ ]),
1942
+ onCompleteIds: [],
1943
+ });
1944
+ await testWaitForWorkerMessage();
1945
+ assertEqual(evolu.getQueryRows(todoTitleQuery), [{ title: "Synced" }]);
1946
+ const cachedLoad = evolu.loadQuery(todoTitleQuery);
1947
+ assertEqual(await cachedLoad, [{ title: "Synced" }]);
1948
+ await testWaitForWorkerMessage();
1949
+ assertEqual(evoluInputs, []);
1950
+
1951
+ // The next invalidation drops the unsubscribed cache without a read.
1952
+ postEvoluOutput({ type: "RefreshQueries" });
1953
+ await testWaitForWorkerMessage();
1954
+ assertEqual(evoluInputs, []);
1955
+ const reload = evolu.loadQuery(todoTitleQuery);
1956
+ assertNotSame(reload, cachedLoad);
1957
+ await testWaitForWorkerMessage();
1958
+ assertEqual(evoluInputs, [
1959
+ { type: "Query", queries: new Set([todoTitleQuery]) },
1960
+ ]);
1961
+ });
1962
+
1963
+ it("subscribeQuery reuses the refreshing entry for repeated subscriptions in one tick", async () => {
1964
+ await using setup = await setupRunWithEvoluDeps();
1965
+ const { run, evoluInputs, postEvoluOutput } = setup;
1966
+ const evolu = await run.ok(testCreateEvolu);
1967
+
1968
+ const initialLoad = evolu.loadQuery(todoTitleQuery);
1969
+ await testWaitForWorkerMessage();
1970
+ postEvoluOutput({
1971
+ type: "OnPatchesByQuery",
1972
+ patchesByQuery: new Map([
1973
+ [todoTitleQuery, [{ op: "replaceAll", value: [] }]],
1974
+ ]),
1975
+ onCompleteIds: [],
1976
+ });
1977
+ await initialLoad;
1978
+ postEvoluOutput({ type: "RefreshQueries" });
1979
+ await testWaitForWorkerMessage();
1980
+ evoluInputs.length = 0;
1981
+
1982
+ // React StrictMode subscribes, unsubscribes, and subscribes again
1983
+ // synchronously.
1984
+ const unsubscribeFirst = evolu.subscribeQuery(todoTitleQuery)(constVoid);
1985
+ const refreshingLoad = evolu.loadQuery(todoTitleQuery);
1986
+ unsubscribeFirst();
1987
+ const unsubscribe = evolu.subscribeQuery(todoTitleQuery)(constVoid);
1988
+ assertSame(evolu.loadQuery(todoTitleQuery), refreshingLoad);
1989
+ await testWaitForWorkerMessage();
1990
+ assertEqual(evoluInputs, [
1991
+ { type: "Query", queries: new Set([todoTitleQuery]) },
1992
+ ]);
1993
+ unsubscribe();
1994
+ });
1995
+
1318
1996
  it("loadQuery reuses pending promise and sends one Query message", async () => {
1319
1997
  await using setup = await setupRunWithEvoluDeps();
1320
1998
  const { run, evoluInputs } = setup;
@@ -1399,7 +2077,7 @@ describe("Evolu", () => {
1399
2077
  );
1400
2078
  });
1401
2079
 
1402
- it("RefreshQueries re-queries pending unsubscribed loadQuery", async () => {
2080
+ it("RefreshQueries reads a pending unsubscribed loadQuery once, when it is subscribed", async () => {
1403
2081
  await using setup = await setupRunWithEvoluDeps();
1404
2082
  const { run, evoluInputs, postEvoluOutput } = setup;
1405
2083
  const evolu = await run.ok(testCreateEvolu);
@@ -1409,12 +2087,18 @@ describe("Evolu", () => {
1409
2087
 
1410
2088
  evoluInputs.length = 0;
1411
2089
  postEvoluOutput({ type: "RefreshQueries" });
2090
+ await testWaitForWorkerMessage();
1412
2091
 
2092
+ assertEqual(evoluInputs, []);
2093
+
2094
+ const unsubscribe = evolu.subscribeQuery(todoTitleQuery)(constVoid);
1413
2095
  await testWaitForWorkerMessage();
1414
2096
 
1415
2097
  assertEqual(evoluInputs, [
1416
2098
  { type: "Query", queries: new Set([todoTitleQuery]) },
1417
2099
  ]);
2100
+
2101
+ unsubscribe();
1418
2102
  });
1419
2103
 
1420
2104
  it("RefreshQueries re-queries subscribed query without loadQuery", async () => {
@@ -1782,6 +2466,294 @@ describe("Evolu", () => {
1782
2466
  });
1783
2467
  });
1784
2468
 
2469
+ describe("mutation size", () => {
2470
+ const NoteId = id("Note");
2471
+ const note = {
2472
+ id: NoteId,
2473
+ body: nullOr(StringType),
2474
+ data: nullOr(Uint8ArrayType),
2475
+ count: nullOr(FiniteNumber),
2476
+ };
2477
+ const SizeSchema = { note, _note: note };
2478
+
2479
+ const createSizeEvolu = createEvolu(SizeSchema, {
2480
+ appName: testAppName,
2481
+ appOwner: testAppOwner,
2482
+ transports: [],
2483
+ });
2484
+
2485
+ const noteId = NoteId.orThrow(createIdFromString("note"));
2486
+
2487
+ const getChanges = (inputs: ReadonlyArray<EvoluInput>) =>
2488
+ inputs.flatMap((input) => (input.type === "Mutate" ? input.changes : []));
2489
+
2490
+ const tooLargeMessage = (size: number) =>
2491
+ `The mutation of table 'note' is ${size} bytes, over maxMutationSize (640000). Bound the Types of its columns or check it with evolu.getMutationSize.`;
2492
+
2493
+ // The length of a blob that makes a note exactly maxMutationSize. Its
2494
+ // length varint takes three bytes, two more than an empty blob's.
2495
+ const getDataAtLimitLength = (evolu: Evolu<typeof SizeSchema>) =>
2496
+ maxMutationSize -
2497
+ evolu.getMutationSize("note", { data: new Uint8Array() }) -
2498
+ 2;
2499
+
2500
+ it("getMutationSize measures the change as it is encoded", async () => {
2501
+ await using setup = await setupRunWithEvoluDeps();
2502
+ const evolu = await setup.run.ok(createSizeEvolu);
2503
+
2504
+ const values = {
2505
+ body: "Milk",
2506
+ data: new Uint8Array(10),
2507
+ count: FiniteNumber.orThrow(0.1),
2508
+ };
2509
+ const buffer = createBuffer();
2510
+ encodeDbChange(buffer, {
2511
+ timestamp: createTimestamp(),
2512
+ change: DbChange.orThrow({
2513
+ table: "note",
2514
+ id: noteId,
2515
+ values,
2516
+ isInsert: false,
2517
+ isDelete: true,
2518
+ }),
2519
+ });
2520
+ assertEqual(evolu.getMutationSize("note", values), buffer.getLength());
2521
+
2522
+ // `id` and `isDeleted` are not columns.
2523
+ assertEqual(
2524
+ evolu.getMutationSize("note", { id: noteId, isDeleted: 1 }),
2525
+ evolu.getMutationSize("note", {}),
2526
+ );
2527
+
2528
+ // @ts-expect-error Only the schema's tables can be measured.
2529
+ evolu.getMutationSize("unknown", {});
2530
+ // @ts-expect-error Values must match their column's Type.
2531
+ evolu.getMutationSize("note", { body: 1 });
2532
+ });
2533
+
2534
+ it("encodes no string in more than three bytes per code unit", async () => {
2535
+ await using setup = await setupRunWithEvoluDeps();
2536
+ const evolu = await setup.run.ok(createSizeEvolu);
2537
+
2538
+ const assertAtMostThreeBytesPerCodeUnit = (value: string) => {
2539
+ // A string also takes a length varint of up to three bytes, besides
2540
+ // the type tag that null takes too.
2541
+ assertTrue(
2542
+ evolu.getMutationSize("note", { body: value }) -
2543
+ evolu.getMutationSize("note", { body: null }) <=
2544
+ 3 * value.length + 3,
2545
+ );
2546
+ };
2547
+
2548
+ for (const value of [
2549
+ "",
2550
+ "€".repeat(200_000),
2551
+ "😀".repeat(1_000),
2552
+ "\uD800".repeat(1_000),
2553
+ // JSON takes exactly three bytes per code unit here.
2554
+ "0.1",
2555
+ `[${"0.1,".repeat(999)}0.1]`,
2556
+ "[".repeat(1_001) + "]".repeat(1_001),
2557
+ noteId,
2558
+ "2024-01-01T00:00:00.000Z",
2559
+ ]) {
2560
+ assertAtMostThreeBytesPerCodeUnit(value);
2561
+ }
2562
+
2563
+ fc.assert(
2564
+ fc.property(
2565
+ fc.oneof(fc.string(), fc.string({ unit: "binary" }), fc.json()),
2566
+ assertAtMostThreeBytesPerCodeUnit,
2567
+ ),
2568
+ { numRuns: 2_000 },
2569
+ );
2570
+ });
2571
+
2572
+ it("rejects a mutation over maxMutationSize before batching it", async () => {
2573
+ await using setup = await setupRunWithEvoluDeps();
2574
+ const { run, evoluInputs, postEvoluOutput } = setup;
2575
+ const evolu = await run.ok(createSizeEvolu);
2576
+
2577
+ const dataAtLimitLength = getDataAtLimitLength(evolu);
2578
+ const atLimit = { data: new Uint8Array(dataAtLimitLength) };
2579
+ assertEqual(evolu.getMutationSize("note", atLimit), maxMutationSize);
2580
+ const completed: Array<string> = [];
2581
+
2582
+ const { id } = evolu.insert("note", atLimit, {
2583
+ onComplete: () => {
2584
+ completed.push("at limit");
2585
+ },
2586
+ });
2587
+ const error = assertThrowsInstanceOf(
2588
+ () =>
2589
+ evolu.insert(
2590
+ "note",
2591
+ { data: new Uint8Array(dataAtLimitLength + 1) },
2592
+ {
2593
+ onComplete: () => {
2594
+ completed.push("over limit");
2595
+ },
2596
+ },
2597
+ ),
2598
+ Error,
2599
+ );
2600
+ assertEqual(error.message, tooLargeMessage(maxMutationSize + 1));
2601
+
2602
+ await testWaitForWorkerMessage();
2603
+
2604
+ // The mutation made before the rejected one is sent as usual.
2605
+ assertEqual(
2606
+ getChanges(evoluInputs).map((change) => change.id),
2607
+ [id],
2608
+ );
2609
+ const mutate = evoluInputs.find((input) => input.type === "Mutate");
2610
+ assertNotUndefined(mutate);
2611
+ postEvoluOutput({
2612
+ type: "OnPatchesByQuery",
2613
+ patchesByQuery: new Map(),
2614
+ onCompleteIds: mutate.onCompleteIds,
2615
+ });
2616
+ await testWaitForWorkerMessage();
2617
+ assertEqual(completed, ["at limit"]);
2618
+ });
2619
+
2620
+ it("lets plain text use the whole limit at a byte per character", async () => {
2621
+ await using setup = await setupRunWithEvoluDeps();
2622
+ const { run, evoluInputs } = setup;
2623
+ const evolu = await run.ok(createSizeEvolu);
2624
+
2625
+ // Spaces keep the text from being encoded as Base64Url, which is smaller.
2626
+ // A long text's length varint takes two more bytes than a short one's.
2627
+ const length =
2628
+ maxMutationSize - evolu.getMutationSize("note", { body: " " }) - 1;
2629
+ assertEqual(
2630
+ evolu.getMutationSize("note", { body: " ".repeat(length) }),
2631
+ maxMutationSize,
2632
+ );
2633
+ assertTrue(length > 639_900);
2634
+
2635
+ evolu.update("note", { id: noteId, body: " ".repeat(length) });
2636
+ const error = assertThrowsInstanceOf(
2637
+ () =>
2638
+ evolu.update("note", { id: noteId, body: " ".repeat(length + 1) }),
2639
+ Error,
2640
+ );
2641
+ assertEqual(error.message, tooLargeMessage(maxMutationSize + 1));
2642
+
2643
+ await testWaitForWorkerMessage();
2644
+ assertLength(getChanges(evoluInputs), 1);
2645
+ });
2646
+
2647
+ it("saves a copy of each binary value", async () => {
2648
+ await using setup = await setupRunWithEvoluDeps();
2649
+ const { run, evoluInputs } = setup;
2650
+ const evolu = await run.ok(createSizeEvolu);
2651
+
2652
+ const resizable = new ArrayBuffer(3, { maxByteLength: 8 });
2653
+ const growing = new Uint8Array(resizable);
2654
+ growing.set([1, 2, 3]);
2655
+ const shared = new Uint8Array(new SharedArrayBuffer(3));
2656
+ shared.set([4, 5, 6]);
2657
+ const nodeBuffer = Buffer.from([7, 8, 9]);
2658
+
2659
+ evolu.insert("note", { data: growing });
2660
+ evolu.insert("note", { data: shared });
2661
+ evolu.insert("note", { data: nodeBuffer });
2662
+
2663
+ // Changes made before the batch is sent do not reach it.
2664
+ resizable.resize(8);
2665
+ growing.fill(0);
2666
+ shared.fill(0);
2667
+ nodeBuffer.fill(0);
2668
+
2669
+ await testWaitForWorkerMessage();
2670
+
2671
+ const saved = getChanges(evoluInputs).map((change) => change.values.data);
2672
+ assertEqual(saved, [
2673
+ new Uint8Array([1, 2, 3]),
2674
+ new Uint8Array([4, 5, 6]),
2675
+ new Uint8Array([7, 8, 9]),
2676
+ ]);
2677
+ for (const data of saved) {
2678
+ assert(data instanceof Uint8Array, "Expected a Uint8Array.");
2679
+ assertSame(Object.getPrototypeOf(data), Uint8Array.prototype);
2680
+ assertFalse(data.buffer instanceof SharedArrayBuffer);
2681
+ assertFalse((data.buffer as ArrayBuffer).resizable);
2682
+ }
2683
+ });
2684
+
2685
+ it("does not limit local-only tables but copies their binary values", async () => {
2686
+ await using setup = await setupRunWithEvoluDeps();
2687
+ const { run, evoluInputs } = setup;
2688
+ const evolu = await run.ok(createSizeEvolu);
2689
+
2690
+ const data = new Uint8Array(maxMutationSize + 1);
2691
+ evolu.insert("_note", { data });
2692
+ data[0] = 1;
2693
+
2694
+ await testWaitForWorkerMessage();
2695
+
2696
+ const [change] = getChanges(evoluInputs);
2697
+ assertNotUndefined(change);
2698
+ const saved = change.values.data;
2699
+ assert(saved instanceof Uint8Array, "Expected a Uint8Array.");
2700
+ assertNotSame(saved, data);
2701
+ assertEqual(saved.byteLength, maxMutationSize + 1);
2702
+ assertEqual(saved[0], 0);
2703
+ });
2704
+
2705
+ it("fits a change at the limit into one message next to the largest ranges section", async () => {
2706
+ await using setup = await setupRunWithEvoluDeps();
2707
+ const evolu = await setup.run.ok(createSizeEvolu);
2708
+ const deps = testCreateDeps();
2709
+ const values = { data: new Uint8Array(getDataAtLimitLength(evolu)) };
2710
+ assertEqual(evolu.getMutationSize("note", values), maxMutationSize);
2711
+
2712
+ const message = {
2713
+ timestamp: createTimestamp(),
2714
+ change: DbChange.orThrow({
2715
+ table: "note",
2716
+ id: noteId,
2717
+ values,
2718
+ isInsert: true,
2719
+ isDelete: null,
2720
+ }),
2721
+ };
2722
+
2723
+ // A request and a broadcast assert that the change fits.
2724
+ createProtocolMessageFromCrdtMessages(deps)(testAppOwner, [message]);
2725
+ createProtocolBroadcastMessagesFromCrdtMessages(deps)(testAppOwner, [
2726
+ message,
2727
+ ]);
2728
+
2729
+ // A relay's response can pair it with the largest ranges section.
2730
+ const rangesMaxSize = ProtocolMessageRangesMaxSize.orThrow(100_000);
2731
+ const timestamps = createTimestampsBuffer();
2732
+ for (let index = 1; timestamps.getLength() < 99_900; index++) {
2733
+ timestamps.add(
2734
+ createTimestamp({
2735
+ millis: createTimestamp().millis,
2736
+ nodeId: index.toString(16).padStart(16, "0") as NodeId,
2737
+ }),
2738
+ );
2739
+ }
2740
+ const response = createProtocolMessageBuffer(testAppOwner.id, {
2741
+ messageType: MessageType.Response,
2742
+ errorCode: ProtocolErrorCode.NoError,
2743
+ rangesMaxSize,
2744
+ });
2745
+ assertTrue(
2746
+ response.canAddTimestampsRangeAndMessage(timestamps, {
2747
+ timestamp: message.timestamp,
2748
+ change: encodeAndEncryptDbChange(deps)(
2749
+ message,
2750
+ testAppOwner.encryptionKey,
2751
+ ),
2752
+ }),
2753
+ );
2754
+ });
2755
+ });
2756
+
1785
2757
  describe("exportDatabase", () => {
1786
2758
  it("throws when OnExport arrives without pending export", async () => {
1787
2759
  const channels: Array<{