@evolu/common 8.10.0 → 8.11.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 (144) hide show
  1. package/dist/src/Config.d.ts +22 -22
  2. package/dist/src/Config.d.ts.map +1 -1
  3. package/dist/src/Console.d.ts +62 -7
  4. package/dist/src/Console.d.ts.map +1 -1
  5. package/dist/src/Console.js +20 -4
  6. package/dist/src/Crypto.d.ts +76 -4
  7. package/dist/src/Crypto.d.ts.map +1 -1
  8. package/dist/src/Crypto.js +55 -4
  9. package/dist/src/Error.d.ts +45 -0
  10. package/dist/src/Error.d.ts.map +1 -1
  11. package/dist/src/Error.js +69 -0
  12. package/dist/src/Fs.d.ts +92 -18
  13. package/dist/src/Fs.d.ts.map +1 -1
  14. package/dist/src/Fs.js +2 -0
  15. package/dist/src/Identicon.d.ts +2 -2
  16. package/dist/src/Identicon.js +2 -2
  17. package/dist/src/LeakDetector.d.ts +22 -3
  18. package/dist/src/LeakDetector.d.ts.map +1 -1
  19. package/dist/src/LeakDetector.js +12 -2
  20. package/dist/src/LockManager.d.ts +8 -0
  21. package/dist/src/LockManager.d.ts.map +1 -1
  22. package/dist/src/LockManager.js +6 -0
  23. package/dist/src/Object.d.ts.map +1 -1
  24. package/dist/src/Object.js +5 -0
  25. package/dist/src/Platform.d.ts +47 -7
  26. package/dist/src/Platform.d.ts.map +1 -1
  27. package/dist/src/Platform.js +24 -5
  28. package/dist/src/Random.d.ts +25 -2
  29. package/dist/src/Random.d.ts.map +1 -1
  30. package/dist/src/Random.js +14 -2
  31. package/dist/src/Resource.d.ts +156 -1
  32. package/dist/src/Resource.d.ts.map +1 -1
  33. package/dist/src/Resource.js +201 -72
  34. package/dist/src/Schedule.d.ts +11 -10
  35. package/dist/src/Schedule.d.ts.map +1 -1
  36. package/dist/src/Schedule.js +1 -1
  37. package/dist/src/Sqlite.d.ts +132 -16
  38. package/dist/src/Sqlite.d.ts.map +1 -1
  39. package/dist/src/Sqlite.js +63 -9
  40. package/dist/src/Task.d.ts +15 -4
  41. package/dist/src/Task.d.ts.map +1 -1
  42. package/dist/src/Task.js +41 -15
  43. package/dist/src/Test.d.ts +9 -0
  44. package/dist/src/Test.d.ts.map +1 -1
  45. package/dist/src/Test.js +4 -0
  46. package/dist/src/Time.d.ts +106 -9
  47. package/dist/src/Time.d.ts.map +1 -1
  48. package/dist/src/Time.js +55 -4
  49. package/dist/src/Type.d.ts +1455 -1310
  50. package/dist/src/Type.d.ts.map +1 -1
  51. package/dist/src/Type.js +1274 -517
  52. package/dist/src/WebSocket.d.ts +164 -13
  53. package/dist/src/WebSocket.d.ts.map +1 -1
  54. package/dist/src/WebSocket.js +133 -24
  55. package/dist/src/Worker.d.ts +90 -8
  56. package/dist/src/Worker.d.ts.map +1 -1
  57. package/dist/src/Worker.js +28 -2
  58. package/dist/src/index.d.ts +6 -7
  59. package/dist/src/index.d.ts.map +1 -1
  60. package/dist/src/index.js +2 -3
  61. package/dist/src/local-first/Db.d.ts +52 -3
  62. package/dist/src/local-first/Db.d.ts.map +1 -1
  63. package/dist/src/local-first/Db.js +412 -137
  64. package/dist/src/local-first/Evolu.d.ts +336 -211
  65. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  66. package/dist/src/local-first/Evolu.js +102 -15
  67. package/dist/src/local-first/Owner.d.ts +13 -30
  68. package/dist/src/local-first/Owner.d.ts.map +1 -1
  69. package/dist/src/local-first/Owner.js +13 -30
  70. package/dist/src/local-first/Protocol.d.ts +94 -16
  71. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  72. package/dist/src/local-first/Protocol.js +118 -38
  73. package/dist/src/local-first/Query.d.ts +8 -15
  74. package/dist/src/local-first/Query.d.ts.map +1 -1
  75. package/dist/src/local-first/Schema.d.ts +335 -21
  76. package/dist/src/local-first/Schema.d.ts.map +1 -1
  77. package/dist/src/local-first/Schema.js +214 -17
  78. package/dist/src/local-first/Shared.d.ts +537 -22
  79. package/dist/src/local-first/Shared.d.ts.map +1 -1
  80. package/dist/src/local-first/Shared.js +1437 -234
  81. package/dist/src/local-first/Storage.d.ts +192 -14
  82. package/dist/src/local-first/Storage.d.ts.map +1 -1
  83. package/dist/src/local-first/Storage.js +81 -20
  84. package/dist/src/local-first/Timestamp.d.ts +392 -41
  85. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  86. package/dist/src/local-first/Timestamp.js +403 -81
  87. package/dist/src/local-first/index.d.ts +0 -1
  88. package/dist/src/local-first/index.d.ts.map +1 -1
  89. package/dist/src/local-first/index.js +0 -1
  90. package/package.json +1 -1
  91. package/src/Assert.test.ts +2 -5
  92. package/src/Config.test.ts +2 -6
  93. package/src/Config.ts +133 -133
  94. package/src/Console.ts +62 -7
  95. package/src/Crypto.ts +76 -4
  96. package/src/Eq.test.ts +2 -3
  97. package/src/Error.test.ts +76 -3
  98. package/src/Error.ts +71 -0
  99. package/src/Fs.ts +92 -18
  100. package/src/Identicon.ts +2 -2
  101. package/src/LeakDetector.ts +22 -3
  102. package/src/LockManager.ts +8 -0
  103. package/src/Object.test.ts +27 -12
  104. package/src/Object.ts +5 -0
  105. package/src/Platform.ts +50 -8
  106. package/src/Random.ts +25 -2
  107. package/src/Resource.test.ts +837 -0
  108. package/src/Resource.ts +235 -15
  109. package/src/Schedule.test.ts +50 -12
  110. package/src/Schedule.ts +24 -14
  111. package/src/Sqlite.ts +137 -17
  112. package/src/Task.test.ts +189 -8
  113. package/src/Task.ts +56 -17
  114. package/src/Test.ts +9 -0
  115. package/src/Time.ts +106 -9
  116. package/src/Type.test.ts +946 -1028
  117. package/src/Type.ts +4195 -3136
  118. package/src/Types.test.ts +4 -14
  119. package/src/WebSocket.ts +313 -40
  120. package/src/Worker.ts +90 -8
  121. package/src/index.ts +15 -6
  122. package/src/local-first/Db.ts +644 -339
  123. package/src/local-first/Evolu.test.ts +686 -21
  124. package/src/local-first/Evolu.ts +450 -228
  125. package/src/local-first/Owner.ts +13 -30
  126. package/src/local-first/Protocol.test.ts +617 -10
  127. package/src/local-first/Protocol.ts +196 -72
  128. package/src/local-first/Query.ts +8 -15
  129. package/src/local-first/Schema.test.ts +143 -0
  130. package/src/local-first/Schema.ts +363 -24
  131. package/src/local-first/Shared.test.ts +7731 -559
  132. package/src/local-first/Shared.ts +2036 -267
  133. package/src/local-first/Storage.ts +218 -32
  134. package/src/local-first/Timestamp.test.ts +344 -70
  135. package/src/local-first/Timestamp.ts +434 -118
  136. package/src/local-first/index.ts +0 -1
  137. package/dist/src/local-first/Error.d.ts +0 -12
  138. package/dist/src/local-first/Error.d.ts.map +0 -1
  139. package/dist/src/local-first/Error.js +0 -6
  140. package/dist/src/local-first/LocalAuth.d.ts +0 -150
  141. package/dist/src/local-first/LocalAuth.d.ts.map +0 -1
  142. package/dist/src/local-first/LocalAuth.js +0 -179
  143. package/src/local-first/Error.ts +0 -17
  144. package/src/local-first/LocalAuth.ts +0 -457
@@ -18,15 +18,22 @@ import type { Brand } from "../Brand.ts";
18
18
  import type { ConsoleEntry, TestConsole } from "../Console.ts";
19
19
  import { testCreateConsole } from "../Console.ts";
20
20
  import { constVoid } from "../Function.ts";
21
- import type { DbWorkerInit } from "./Db.ts";
21
+ import type { DbWorkerInit, UnsupportedDbVersionError } from "./Db.ts";
22
22
  import {
23
23
  AppName,
24
24
  createEvolu,
25
25
  createEvoluDeps,
26
+ type Evolu,
27
+ type EvoluConfig,
26
28
  testAppName,
27
29
  type EvoluPlatformDeps,
28
30
  } from "./Evolu.ts";
29
- import { createOwnerWebSocketTransport, testAppOwner } from "./Owner.ts";
31
+ import {
32
+ createOwnerSecret,
33
+ createOwnerWebSocketTransport,
34
+ createSharedOwner,
35
+ testAppOwner,
36
+ } from "./Owner.ts";
30
37
  import { createQueryBuilder } from "./Schema.ts";
31
38
  import {
32
39
  consoleEntryOrErrorBroadcastChannelName,
@@ -36,21 +43,30 @@ import {
36
43
  type EvoluInput,
37
44
  type EvoluOutput,
38
45
  type SharedWorker,
46
+ type SharedWorkerId,
39
47
  type SharedWorkerInput,
40
48
  type SharedWorkerOutput,
49
+ type SyncState,
41
50
  } from "./Shared.ts";
42
- import { testCreateLockManager } from "../LockManager.ts";
51
+ import {
52
+ acquireLeaderLock,
53
+ type LockManagerDep,
54
+ testCreateLockManager,
55
+ } from "../LockManager.ts";
43
56
  import { installPolyfills } from "../Polyfills.ts";
44
57
  import { err, ok } from "../Result.ts";
45
58
  import { SqliteBoolean } from "../Sqlite.ts";
46
59
  import { explicitAbortReason, testCreateRun } from "../Task.ts";
60
+ import { testCreateId } from "../Test.ts";
47
61
  import {
48
62
  assertType,
49
63
  createIdFromString,
50
64
  id,
51
65
  NonEmptyTrimmedString100,
52
66
  nullOr,
67
+ PositiveInt,
53
68
  testName,
69
+ Name,
54
70
  } from "../Type.ts";
55
71
  import type { ExtractTyped } from "../Type.ts";
56
72
  import {
@@ -203,6 +219,8 @@ describe("Evolu", () => {
203
219
  }),
204
220
  );
205
221
 
222
+ assertThrowsInstanceOf(() => AppName.orThrow("a".repeat(42)), Error);
223
+
206
224
  const appName = AppName.orThrow("my-app");
207
225
  assertType<typeof appName, AppName>();
208
226
  assertType<typeof AppName.Input, string>();
@@ -212,12 +230,22 @@ describe("Evolu", () => {
212
230
  describe("createEvoluDeps", () => {
213
231
  const setupCreateEvoluDeps = async (
214
232
  console: TestConsole = testCreateConsole(),
233
+ lockManager: LockManagerDep["lockManager"] = testCreateLockManager(),
215
234
  ): Promise<{
216
235
  readonly deps: ReturnType<typeof createEvoluDeps>;
217
236
  readonly messages: Array<SharedWorkerInput>;
237
+ readonly sharedWorkerPort: MessagePort<
238
+ SharedWorkerOutput,
239
+ SharedWorkerInput
240
+ >;
218
241
  readonly consoleEntryOrErrorBroadcastChannel: {
219
242
  readonly postMessage: (message: ConsoleEntryOrError) => void;
220
243
  };
244
+ /**
245
+ * Sends the worker's Connected message, as a worker holding its lock
246
+ * does.
247
+ */
248
+ readonly connect: (workerId: SharedWorkerId) => Promise<void>;
221
249
  readonly [Symbol.dispose]: () => void;
222
250
  }> => {
223
251
  using disposer = new DisposableStack();
@@ -226,7 +254,11 @@ describe("Evolu", () => {
226
254
  );
227
255
 
228
256
  const messages: Array<SharedWorkerInput> = [];
257
+ const sharedWorkerPort: {
258
+ value: MessagePort<SharedWorkerOutput, SharedWorkerInput> | null;
259
+ } = { value: null };
229
260
  worker.self.onConnect = (port) => {
261
+ sharedWorkerPort.value = port;
230
262
  port.onMessage = (message) => {
231
263
  messages.push(message);
232
264
  };
@@ -238,7 +270,7 @@ describe("Evolu", () => {
238
270
  createDbWorker: testCreateWorker,
239
271
  createBroadcastChannel: testCreateBroadcastChannel,
240
272
  createMessageChannel: testCreateMessageChannel,
241
- lockManager: testCreateLockManager(),
273
+ lockManager,
242
274
  sharedWorker: worker,
243
275
  reloadApp: constVoid,
244
276
  console,
@@ -247,8 +279,10 @@ describe("Evolu", () => {
247
279
 
248
280
  await testWaitForWorkerMessage();
249
281
 
250
- assertLength(messages, 1);
251
- assertSame(messages[0].type, "AnnounceTabLeader");
282
+ // A tab says nothing until its worker connects.
283
+ assertEqual(messages, []);
284
+ const port = sharedWorkerPort.value;
285
+ assertNotNull(port);
252
286
  const consoleEntryOrErrorBroadcastChannel = disposer.use(
253
287
  testCreateBroadcastChannel<ConsoleEntryOrError>(
254
288
  consoleEntryOrErrorBroadcastChannelName,
@@ -259,21 +293,110 @@ describe("Evolu", () => {
259
293
  return {
260
294
  deps,
261
295
  messages,
296
+ sharedWorkerPort: port,
262
297
  consoleEntryOrErrorBroadcastChannel,
298
+ connect: async (workerId) => {
299
+ port.postMessage({
300
+ type: "Connected",
301
+ workerId,
302
+ syncStateChannelName: `evolu:sync-state:${workerId}`,
303
+ });
304
+ await testWaitForWorkerMessage();
305
+ },
263
306
  [Symbol.dispose]: () => disposables.dispose(),
264
307
  };
265
308
  };
266
309
 
267
- it("posts AnnounceTabLeader with console level to worker", async () => {
310
+ it("reports an OtherBuildRunningError its worker sends and clears it once the worker connects", async () => {
311
+ using setup = await setupCreateEvoluDeps();
312
+
313
+ setup.sharedWorkerPort.postMessage({
314
+ type: "Error",
315
+ error: { type: "OtherBuildRunningError" },
316
+ });
317
+ await testWaitForWorkerMessage();
318
+ assertEqual(setup.deps.evoluError.get(), {
319
+ type: "OtherBuildRunningError",
320
+ });
321
+
322
+ await setup.connect(testCreateId()<"SharedWorker">());
323
+ assertSame(setup.deps.evoluError.get(), null);
324
+ });
325
+
326
+ it("keeps another error when its worker connects", async () => {
327
+ using setup = await setupCreateEvoluDeps();
328
+ const error: UnsupportedDbVersionError = {
329
+ type: "UnsupportedDbVersionError",
330
+ storedVersion: PositiveInt.orThrow(3),
331
+ supportedVersion: PositiveInt.orThrow(2),
332
+ };
333
+
334
+ setup.sharedWorkerPort.postMessage({ type: "Error", error });
335
+ await testWaitForWorkerMessage();
336
+ await setup.connect(testCreateId()<"SharedWorker">());
337
+
338
+ assertEqual(setup.deps.evoluError.get(), error);
339
+ });
340
+
341
+ it("says nothing to a worker that reports it waits", async () => {
342
+ using setup = await setupCreateEvoluDeps();
343
+
344
+ setup.sharedWorkerPort.postMessage({
345
+ type: "Waiting",
346
+ workerId: testCreateId()<"SharedWorker">(),
347
+ });
348
+ await testWaitForWorkerMessage();
349
+
350
+ assertEqual(setup.messages, []);
351
+ assertSame(setup.deps.evoluError.get(), null);
352
+ });
353
+
354
+ it("says nothing to a worker that reports it keeps databases in memory", async () => {
355
+ using setup = await setupCreateEvoluDeps();
356
+
357
+ setup.sharedWorkerPort.postMessage({ type: "StorageUnavailable" });
358
+ await testWaitForWorkerMessage();
359
+
360
+ assertEqual(setup.messages, []);
361
+ assertSame(setup.deps.evoluError.get(), null);
362
+ });
363
+
364
+ it("announces itself as tab leader with its console level once its worker connects", async () => {
268
365
  const testConsole = testCreateConsole();
269
366
  using setup = await setupCreateEvoluDeps(testConsole);
270
- const { messages } = setup;
367
+ const { messages, connect } = setup;
271
368
 
272
- assertLength(messages, 1);
273
- assertEqual(messages[0], {
274
- type: "AnnounceTabLeader",
275
- consoleLevel: testConsole.getLevel(),
276
- });
369
+ await connect(testCreateId()<"SharedWorker">());
370
+
371
+ assertEqual(messages, [
372
+ { type: "RequestSyncState" },
373
+ { type: "AnnounceTabLeader", consoleLevel: testConsole.getLevel() },
374
+ ]);
375
+ });
376
+
377
+ it("competes for tab leadership only with tabs of its worker", async () => {
378
+ const lockManager = testCreateLockManager();
379
+ await using run = testCreateRun({ lockManager });
380
+ const createWorkerId = testCreateId();
381
+ const worker = createWorkerId<"SharedWorker">();
382
+ // The tab's worker holds this lock for its lifetime.
383
+ await using _workerBuildLock = await run.ok(acquireLeaderLock("tab"));
384
+ using first = await setupCreateEvoluDeps(undefined, lockManager);
385
+ using second = await setupCreateEvoluDeps(undefined, lockManager);
386
+ using ofOtherWorker = await setupCreateEvoluDeps(undefined, lockManager);
387
+ await first.connect(worker);
388
+ await second.connect(worker);
389
+ await ofOtherWorker.connect(createWorkerId<"SharedWorker">());
390
+ const isLeader = (setup: typeof first): boolean =>
391
+ setup.messages.some((message) => message.type === "AnnounceTabLeader");
392
+
393
+ assertTrue(isLeader(first));
394
+ assertFalse(isLeader(second));
395
+ assertTrue(isLeader(ofOtherWorker));
396
+
397
+ first[Symbol.dispose]();
398
+ await testWaitForWorkerMessage();
399
+ assertTrue(isLeader(second));
277
400
  });
278
401
 
279
402
  it("initializes db worker from shared worker init output", async () => {
@@ -367,6 +490,11 @@ describe("Evolu", () => {
367
490
  port.onMessage = (message) => {
368
491
  messages.push(message);
369
492
  };
493
+ port.postMessage({
494
+ type: "Connected",
495
+ workerId: testCreateId()<"SharedWorker">(),
496
+ syncStateChannelName: "evolu:sync-state:worker",
497
+ });
370
498
  };
371
499
  worker.connect();
372
500
 
@@ -382,8 +510,11 @@ describe("Evolu", () => {
382
510
  await testWaitForWorkerMessage();
383
511
 
384
512
  assertNotUndefined(deps.console);
385
- assertLength(messages, 1);
386
- assertSame(messages[0].type, "AnnounceTabLeader");
513
+ const messageTypes = ["RequestSyncState", "AnnounceTabLeader"];
514
+ assertEqual(
515
+ messages.map((message) => message.type),
516
+ messageTypes,
517
+ );
387
518
  using consoleEntryOrErrorBroadcastChannel =
388
519
  testCreateBroadcastChannel<ConsoleEntryOrError>(
389
520
  consoleEntryOrErrorBroadcastChannelName,
@@ -396,8 +527,10 @@ describe("Evolu", () => {
396
527
 
397
528
  await testWaitForWorkerMessage();
398
529
 
399
- assertLength(messages, 1);
400
- assertSame(messages[0].type, "AnnounceTabLeader");
530
+ assertEqual(
531
+ messages.map((message) => message.type),
532
+ messageTypes,
533
+ );
401
534
  assertEqual(deps.evoluError.get(), {
402
535
  type: "UnknownError",
403
536
  error: ["boom"],
@@ -428,6 +561,84 @@ describe("Evolu", () => {
428
561
  assertEqual(testConsole.getEntriesSnapshot(), [entry]);
429
562
  });
430
563
 
564
+ it("follows the sync state channel its worker names", async () => {
565
+ using setup = await setupCreateEvoluDeps();
566
+ const { deps, messages, connect } = setup;
567
+ const workerId = testCreateId()<"SharedWorker">();
568
+ using channel = testCreateBroadcastChannel<SyncState>(
569
+ `evolu:sync-state:${workerId}`,
570
+ );
571
+ using otherChannel = testCreateBroadcastChannel<SyncState>(
572
+ "evolu:sync-state:other-worker",
573
+ );
574
+ const createState = (label: string): SyncState => ({
575
+ transports: [],
576
+ tenants: [{ name: Name.orThrow(label), refused: false, owners: [] }],
577
+ });
578
+
579
+ // Nothing reaches the tab before its worker names the channel.
580
+ channel.postMessage(createState("early"));
581
+ await testWaitForWorkerMessage();
582
+ assertSame(deps.syncState.get(), null);
583
+
584
+ await connect(workerId);
585
+ assertEqual(messages.at(0), { type: "RequestSyncState" });
586
+ channel.postMessage(createState("requested"));
587
+ await testWaitForWorkerMessage();
588
+ assertEqual(deps.syncState.get(), createState("requested"));
589
+
590
+ channel.postMessage(createState("changed"));
591
+ await testWaitForWorkerMessage();
592
+ assertEqual(deps.syncState.get(), createState("changed"));
593
+
594
+ // Another worker's channel is never heard.
595
+ otherChannel.postMessage(createState("other"));
596
+ await testWaitForWorkerMessage();
597
+ assertEqual(deps.syncState.get(), createState("changed"));
598
+ });
599
+
600
+ it("listens on its worker's channel before asking for a snapshot", async () => {
601
+ const name = "evolu:sync-state:worker";
602
+ using worker = testCreateSharedWorker<
603
+ SharedWorkerInput,
604
+ SharedWorkerOutput
605
+ >();
606
+ worker.self.onConnect = (port) => {
607
+ port.onMessage = constVoid;
608
+ port.postMessage({
609
+ type: "Connected",
610
+ workerId: testCreateId()<"SharedWorker">(),
611
+ syncStateChannelName: name,
612
+ });
613
+ };
614
+ worker.connect();
615
+ using channel = testCreateBroadcastChannel<SyncState>(name);
616
+ const requested: SyncState = { transports: [], tenants: [] };
617
+ // A worker on another thread can broadcast its answer before the tab's
618
+ // postMessage returns.
619
+ const port = new Proxy(worker.port, {
620
+ get: (target, property) =>
621
+ property === "postMessage"
622
+ ? (message: SharedWorkerInput) => {
623
+ if (message.type === "RequestSyncState")
624
+ channel.postMessage(requested);
625
+ target.postMessage(message);
626
+ }
627
+ : (Reflect.get(target, property) as unknown),
628
+ });
629
+
630
+ using deps = createEvoluDeps({
631
+ createDbWorker: testCreateWorker,
632
+ createBroadcastChannel: testCreateBroadcastChannel,
633
+ createMessageChannel: testCreateMessageChannel,
634
+ lockManager: testCreateLockManager(),
635
+ sharedWorker: { port, [Symbol.dispose]: constVoid },
636
+ reloadApp: constVoid,
637
+ });
638
+ await testWaitForWorkerMessage();
639
+ assertEqual(deps.syncState.get(), requested);
640
+ });
641
+
431
642
  it("maps ConsoleEntry error output to deps.evoluError store", async () => {
432
643
  using setup = await setupCreateEvoluDeps();
433
644
  const { deps, consoleEntryOrErrorBroadcastChannel } = setup;
@@ -497,6 +708,78 @@ describe("Evolu", () => {
497
708
  assertEqual(deps.evoluError.get(), error);
498
709
  });
499
710
 
711
+ for (const source of ["SharedWorker", "Error", "ConsoleEntry"] as const) {
712
+ it(`preserves the first startup refusal while logging a later ${source} message`, async () => {
713
+ const testConsole = testCreateConsole();
714
+ using setup = await setupCreateEvoluDeps(testConsole);
715
+ const { deps, sharedWorkerPort, consoleEntryOrErrorBroadcastChannel } =
716
+ setup;
717
+ assertSame(deps.evoluError.get(), null);
718
+
719
+ const initialError = {
720
+ type: "UnknownError",
721
+ error: "before refusal",
722
+ } as const;
723
+ consoleEntryOrErrorBroadcastChannel.postMessage({
724
+ type: "Error",
725
+ error: initialError,
726
+ });
727
+ await testWaitForWorkerMessage();
728
+ assertEqual(deps.evoluError.get(), initialError);
729
+
730
+ let notifications = 0;
731
+ deps.evoluError.subscribe(() => {
732
+ notifications++;
733
+ });
734
+ const refusal: UnsupportedDbVersionError = {
735
+ type: "UnsupportedDbVersionError",
736
+ storedVersion: PositiveInt.orThrow(2),
737
+ supportedVersion: PositiveInt.orThrow(1),
738
+ };
739
+ sharedWorkerPort.postMessage({ type: "Error", error: refusal });
740
+ await testWaitForWorkerMessage();
741
+ const storedRefusal = deps.evoluError.get();
742
+ assertEqual(storedRefusal, refusal);
743
+ assertSame(notifications, 1);
744
+
745
+ const laterRefusal: UnsupportedDbVersionError = {
746
+ ...refusal,
747
+ storedVersion: PositiveInt.orThrow(3),
748
+ };
749
+ const laterError = {
750
+ type: "UnknownError",
751
+ error: "after refusal",
752
+ } as const;
753
+ const laterEntry: ConsoleEntry = {
754
+ method: "error",
755
+ path: source === "ConsoleEntry" ? ["worker"] : [],
756
+ args: [source === "SharedWorker" ? laterRefusal : laterError],
757
+ };
758
+ if (source === "SharedWorker") {
759
+ sharedWorkerPort.postMessage({ type: "Error", error: laterRefusal });
760
+ } else if (source === "Error") {
761
+ consoleEntryOrErrorBroadcastChannel.postMessage({
762
+ type: "Error",
763
+ error: laterError,
764
+ });
765
+ } else {
766
+ consoleEntryOrErrorBroadcastChannel.postMessage({
767
+ type: "ConsoleEntry",
768
+ entry: laterEntry,
769
+ });
770
+ }
771
+ await testWaitForWorkerMessage();
772
+
773
+ assertSame(deps.evoluError.get(), storedRefusal);
774
+ assertSame(notifications, 1);
775
+ assertEqual(testConsole.getEntriesSnapshot(), [
776
+ { method: "error", path: [], args: [initialError] },
777
+ { method: "error", path: [], args: [refusal] },
778
+ laterEntry,
779
+ ]);
780
+ });
781
+ }
782
+
500
783
  it("throws for unknown tab output type", () => {
501
784
  const consoleEntryOrErrorBroadcastChannel: {
502
785
  value: {
@@ -530,13 +813,18 @@ describe("Evolu", () => {
530
813
  }, Error);
531
814
  });
532
815
 
533
- it("dispose cleans up resources", () => {
816
+ it("dispose cleans up resources", async () => {
534
817
  const worker = testCreateSharedWorker<
535
818
  SharedWorkerInput,
536
819
  SharedWorkerOutput
537
820
  >();
538
821
  worker.self.onConnect = (port) => {
539
822
  port.onMessage = constVoid;
823
+ port.postMessage({
824
+ type: "Connected",
825
+ workerId: testCreateId()<"SharedWorker">(),
826
+ syncStateChannelName: "evolu:sync-state:worker",
827
+ });
540
828
  };
541
829
  worker.connect();
542
830
 
@@ -564,16 +852,93 @@ describe("Evolu", () => {
564
852
  reloadApp: constVoid,
565
853
  });
566
854
 
567
- assertLength(broadcastChannels, 1);
855
+ // The console/error channel, then the sync state channel its worker names.
856
+ assertSame(broadcastChannels.length, 1);
857
+ await testWaitForWorkerMessage();
858
+ assertLength(broadcastChannels, 2);
568
859
  assertFalse(broadcastChannels[0].isDisposed());
860
+ assertFalse(broadcastChannels[1].isDisposed());
569
861
  assertFalse(workerDisposed);
570
862
  deps[Symbol.dispose]();
571
863
  assertTrue(broadcastChannels[0].isDisposed());
864
+ assertTrue(broadcastChannels[1].isDisposed());
572
865
  assertTrue(workerDisposed);
573
866
  });
574
867
  });
575
868
 
869
+ describe("local tables", () => {
870
+ const LocalSchema = { _todo: Schema.todo };
871
+ const config: EvoluConfig = {
872
+ appName: testAppName,
873
+ appOwner: testAppOwner,
874
+ transports: [],
875
+ };
876
+
877
+ it("keeps useOwner available for an instance with only local tables", async () => {
878
+ const messages: Array<SharedWorkerInput> = [];
879
+ await using setup = await setupRunWithEvoluDeps({
880
+ onSharedWorkerPostMessage: (message) => {
881
+ messages.push(message);
882
+ },
883
+ });
884
+ const evolu = await setup.run.ok(createEvolu(LocalSchema, config));
885
+ assertType<typeof evolu, Evolu<typeof LocalSchema>>();
886
+ assertSame(evolu.appOwner, testAppOwner);
887
+ assertEqual(evolu.name, `AppName-${createIdFromString(testAppOwner.id)}`);
888
+ await testWaitForWorkerMessage();
889
+ assertEqual(setup.evoluInputs, []);
890
+ const message = messages.find(
891
+ (message) => message.type === "CreateEvolu",
892
+ );
893
+ assertNotUndefined(message);
894
+ assertSame(message.encryptionKey, testAppOwner.encryptionKey);
895
+ assertFalse(message.memoryOnly);
896
+
897
+ evolu.insert("_todo", {
898
+ title: NonEmptyTrimmedString100.orThrow("Local"),
899
+ });
900
+ await testWaitForWorkerMessage();
901
+ assertTrue(setup.evoluInputs.some((input) => input.type === "Mutate"));
902
+ assertFalse(setup.evoluInputs.some((input) => input.type === "UseOwner"));
903
+ const unuseOwner = evolu.useOwner(testAppOwner, [testOwnerTransport]);
904
+ await testWaitForWorkerMessage();
905
+ assertTrue(setup.evoluInputs.some((input) => input.type === "UseOwner"));
906
+ unuseOwner();
907
+ await evolu[Symbol.asyncDispose]();
908
+ assertThrowsInstanceOf(
909
+ () =>
910
+ evolu.insert("_todo", {
911
+ title: NonEmptyTrimmedString100.orThrow("Disposed"),
912
+ }),
913
+ Error,
914
+ );
915
+ });
916
+ });
917
+
576
918
  describe("createEvolu", () => {
919
+ it("uses the default relay when transports are omitted", async () => {
920
+ await using setup = await setupRunWithEvoluDeps();
921
+ await setup.run.ok(
922
+ createEvolu(Schema, {
923
+ appName: testAppName,
924
+ appOwner: testAppOwner,
925
+ }),
926
+ );
927
+ await testWaitForWorkerMessage();
928
+ const input = setup.evoluInputs.find(
929
+ (input) => input.type === "UseOwner",
930
+ );
931
+ assertNotUndefined(input);
932
+ assertEqual(input.actions, [
933
+ {
934
+ action: "add",
935
+ owner: {
936
+ owner: testAppOwner,
937
+ transports: [{ type: "WebSocket", url: "wss://free.evoluhq.com" }],
938
+ },
939
+ },
940
+ ]);
941
+ });
577
942
  it("resolves name from appName and appOwner hash", async () => {
578
943
  await using setup = await setupRunWithEvoluDeps();
579
944
  const { run } = setup;
@@ -644,6 +1009,70 @@ describe("Evolu", () => {
644
1009
  assertNonNullable(message.evoluPort);
645
1010
  });
646
1011
 
1012
+ describe("requestSync", () => {
1013
+ it("preserves pending owner operations and returns immediately", async () => {
1014
+ await using setup = await setupRunWithEvoluDeps();
1015
+ const { run, evoluInputs } = setup;
1016
+ const evolu = await run.ok(testCreateEvolu);
1017
+ const sharedOwner = createSharedOwner(createOwnerSecret(run.deps));
1018
+ const owner = { owner: sharedOwner, transports: [testOwnerTransport] };
1019
+ assertNotSame(sharedOwner.id, evolu.appOwner.id);
1020
+
1021
+ const unuse = evolu.useOwner(sharedOwner, [testOwnerTransport]);
1022
+ const result = evolu.requestSync(sharedOwner.id);
1023
+ assertType<typeof result, void>();
1024
+ assertSame(result, undefined);
1025
+ unuse();
1026
+ evolu.requestSync(sharedOwner.id);
1027
+ assertEqual(evoluInputs, []);
1028
+
1029
+ await testWaitForWorkerMessage();
1030
+ assertEqual(evoluInputs, [
1031
+ {
1032
+ type: "UseOwner",
1033
+ actions: [{ action: "add", owner }],
1034
+ },
1035
+ {
1036
+ type: "UseOwner",
1037
+ actions: [
1038
+ { action: "sync", ownerId: sharedOwner.id },
1039
+ { action: "remove", owner },
1040
+ ],
1041
+ },
1042
+ {
1043
+ type: "UseOwner",
1044
+ actions: [{ action: "sync", ownerId: sharedOwner.id }],
1045
+ },
1046
+ ]);
1047
+ });
1048
+
1049
+ it("drops pending requests on disposal", async () => {
1050
+ await using setup = await setupRunWithEvoluDeps();
1051
+ const { run, evoluInputs } = setup;
1052
+ const evolu = await run.ok(testCreateEvolu);
1053
+ evolu.requestSync(testAppOwner.id);
1054
+ await evolu[Symbol.asyncDispose]();
1055
+ await testWaitForWorkerMessage();
1056
+ assertEqual(evoluInputs, []);
1057
+ });
1058
+
1059
+ it("preserves registration, mutation, and sync request order", async () => {
1060
+ await using setup = await setupRunWithEvoluDeps();
1061
+ const { run, evoluInputs } = setup;
1062
+ const evolu = await run.ok(testCreateEvolu);
1063
+ evolu.useOwner(testAppOwner, [testOwnerTransport]);
1064
+ evolu.insert("todo", {
1065
+ title: NonEmptyTrimmedString100.orThrow("Before sync"),
1066
+ });
1067
+ evolu.requestSync(testAppOwner.id);
1068
+ await testWaitForWorkerMessage();
1069
+ assertEqual(
1070
+ evoluInputs.map(({ type }) => type),
1071
+ ["UseOwner", "Mutate", "UseOwner"],
1072
+ );
1073
+ });
1074
+ });
1075
+
647
1076
  describe("useOwner", () => {
648
1077
  it("auto-uses appOwner in a microtask when transports are configured", async () => {
649
1078
  await using setup = await setupRunWithEvoluDeps();
@@ -898,7 +1327,7 @@ describe("Evolu", () => {
898
1327
  assertEqual(evoluInputs, [{ type: "Export" }]);
899
1328
  });
900
1329
 
901
- it("throws from sync methods after dispose", async () => {
1330
+ it("throws from synchronous methods after disposal", async () => {
902
1331
  await using setup = await setupRunWithEvoluDeps();
903
1332
  const { run } = setup;
904
1333
  const evolu = await run.ok(testCreateEvolu);
@@ -992,6 +1421,13 @@ describe("Evolu", () => {
992
1421
  }, Error).message,
993
1422
  disposedMessage,
994
1423
  );
1424
+
1425
+ assertEqual(
1426
+ assertThrowsInstanceOf(() => {
1427
+ evolu.requestSync(testAppOwner.id);
1428
+ }, Error).message,
1429
+ disposedMessage,
1430
+ );
995
1431
  });
996
1432
 
997
1433
  it("delete methods are placeholders", async () => {
@@ -1315,6 +1751,229 @@ describe("Evolu", () => {
1315
1751
  });
1316
1752
 
1317
1753
  describe("query behavior", () => {
1754
+ it("subscribeQuery catches a refresh between loading and subscribing", async () => {
1755
+ await using setup = await setupRunWithEvoluDeps();
1756
+ const { run, evoluInputs, postEvoluOutput } = setup;
1757
+ const evolu = await run.ok(testCreateEvolu);
1758
+
1759
+ const initialLoad = evolu.loadQuery(todoTitleQuery);
1760
+ await testWaitForWorkerMessage();
1761
+ postEvoluOutput({
1762
+ type: "OnPatchesByQuery",
1763
+ patchesByQuery: new Map([
1764
+ [todoTitleQuery, [{ op: "replaceAll", value: [] }]],
1765
+ ]),
1766
+ onCompleteIds: [],
1767
+ });
1768
+ const initialRows = await initialLoad;
1769
+ assertEqual(initialRows, []);
1770
+
1771
+ evoluInputs.length = 0;
1772
+ postEvoluOutput({ type: "RefreshQueries" });
1773
+ await testWaitForWorkerMessage();
1774
+ assertEqual(evoluInputs, []);
1775
+
1776
+ let notifications = 0;
1777
+ const unsubscribe = evolu.subscribeQuery(todoTitleQuery)(() => {
1778
+ notifications += 1;
1779
+ });
1780
+ // React use() must still see fulfilled rows if another render happens
1781
+ // before the worker answers the refresh queued by subscription.
1782
+ const cachedLoad = evolu.loadQuery(todoTitleQuery);
1783
+ assert("status" in cachedLoad, "Cached promises expose React status");
1784
+ assertSame(cachedLoad.status, "fulfilled");
1785
+ assert("value" in cachedLoad, "Fulfilled promises expose cached rows");
1786
+ assertSame(cachedLoad.value, initialRows);
1787
+ assertSame(await cachedLoad, initialRows);
1788
+ assertSame(notifications, 0);
1789
+ await testWaitForWorkerMessage();
1790
+ assertEqual(evoluInputs, [
1791
+ { type: "Query", queries: new Set([todoTitleQuery]) },
1792
+ ]);
1793
+
1794
+ postEvoluOutput({
1795
+ type: "OnPatchesByQuery",
1796
+ patchesByQuery: new Map([
1797
+ [
1798
+ todoTitleQuery,
1799
+ [{ op: "replaceAll", value: [{ title: "Synced" }] }],
1800
+ ],
1801
+ ]),
1802
+ onCompleteIds: [],
1803
+ });
1804
+ await testWaitForWorkerMessage();
1805
+ assertEqual(evolu.getQueryRows(todoTitleQuery), [{ title: "Synced" }]);
1806
+ assertEqual(notifications, 1);
1807
+ // React use() sees a new fulfilled promise after the refresh.
1808
+ const refreshedLoad = evolu.loadQuery(todoTitleQuery);
1809
+ assertNotSame(refreshedLoad, cachedLoad);
1810
+ assertEqual(await refreshedLoad, [{ title: "Synced" }]);
1811
+ unsubscribe();
1812
+ });
1813
+
1814
+ it("subscribeQuery catches a mutation while the initial read is pending", async () => {
1815
+ await using setup = await setupRunWithEvoluDeps();
1816
+ const { run, evoluInputs, postEvoluOutput } = setup;
1817
+ const evolu = await run.ok(testCreateEvolu);
1818
+
1819
+ const initialLoad = evolu.loadQuery(todoTitleQuery);
1820
+ await testWaitForWorkerMessage();
1821
+ evolu.insert("todo", { title: NonEmptyTrimmedString100.orThrow("New") });
1822
+ await testWaitForWorkerMessage();
1823
+ evoluInputs.length = 0;
1824
+
1825
+ const unsubscribe = evolu.subscribeQuery(todoTitleQuery)(constVoid);
1826
+ assertSame(evolu.loadQuery(todoTitleQuery), initialLoad);
1827
+ await testWaitForWorkerMessage();
1828
+ assertEqual(evoluInputs, [
1829
+ { type: "Query", queries: new Set([todoTitleQuery]) },
1830
+ ]);
1831
+
1832
+ // The old read completes first. Its promise must still settle, and the
1833
+ // new subscription must retain the loading entry for the fresh response.
1834
+ postEvoluOutput({
1835
+ type: "OnPatchesByQuery",
1836
+ patchesByQuery: new Map([
1837
+ [todoTitleQuery, [{ op: "replaceAll", value: [] }]],
1838
+ ]),
1839
+ onCompleteIds: [],
1840
+ });
1841
+ assertEqual(await initialLoad, []);
1842
+ assertSame(evolu.loadQuery(todoTitleQuery), initialLoad);
1843
+
1844
+ postEvoluOutput({
1845
+ type: "OnPatchesByQuery",
1846
+ patchesByQuery: new Map([
1847
+ [todoTitleQuery, [{ op: "replaceAll", value: [{ title: "New" }] }]],
1848
+ ]),
1849
+ onCompleteIds: [],
1850
+ });
1851
+ await testWaitForWorkerMessage();
1852
+ assertEqual(evolu.getQueryRows(todoTitleQuery), [{ title: "New" }]);
1853
+ assertEqual(await evolu.loadQuery(todoTitleQuery), [{ title: "New" }]);
1854
+ unsubscribe();
1855
+ });
1856
+
1857
+ it("subscribeQuery reuses valid pending and completed reads", async () => {
1858
+ await using setup = await setupRunWithEvoluDeps();
1859
+ const { run, evoluInputs, postEvoluOutput } = setup;
1860
+ const evolu = await run.ok(testCreateEvolu);
1861
+
1862
+ const initialLoad = evolu.loadQuery(todoTitleQuery);
1863
+ await testWaitForWorkerMessage();
1864
+ evoluInputs.length = 0;
1865
+ const unsubscribe = evolu.subscribeQuery(todoTitleQuery)(constVoid);
1866
+ assertSame(evolu.loadQuery(todoTitleQuery), initialLoad);
1867
+ await testWaitForWorkerMessage();
1868
+ assertEqual(evoluInputs, []);
1869
+
1870
+ postEvoluOutput({
1871
+ type: "OnPatchesByQuery",
1872
+ patchesByQuery: new Map([
1873
+ [todoTitleQuery, [{ op: "replaceAll", value: [] }]],
1874
+ ]),
1875
+ onCompleteIds: [],
1876
+ });
1877
+ await initialLoad;
1878
+ unsubscribe();
1879
+
1880
+ const unsubscribeAgain = evolu.subscribeQuery(todoTitleQuery)(constVoid);
1881
+ assertSame(evolu.loadQuery(todoTitleQuery), initialLoad);
1882
+ await testWaitForWorkerMessage();
1883
+ assertEqual(evoluInputs, []);
1884
+ unsubscribeAgain();
1885
+ });
1886
+
1887
+ it("subscribeQuery keeps refreshed rows cached after unsubscribe until the next invalidation", async () => {
1888
+ await using setup = await setupRunWithEvoluDeps();
1889
+ const { run, evoluInputs, postEvoluOutput } = setup;
1890
+ const evolu = await run.ok(testCreateEvolu);
1891
+
1892
+ const initialLoad = evolu.loadQuery(todoTitleQuery);
1893
+ await testWaitForWorkerMessage();
1894
+ postEvoluOutput({
1895
+ type: "OnPatchesByQuery",
1896
+ patchesByQuery: new Map([
1897
+ [todoTitleQuery, [{ op: "replaceAll", value: [] }]],
1898
+ ]),
1899
+ onCompleteIds: [],
1900
+ });
1901
+ await initialLoad;
1902
+ postEvoluOutput({ type: "RefreshQueries" });
1903
+ await testWaitForWorkerMessage();
1904
+ evoluInputs.length = 0;
1905
+
1906
+ const unsubscribe = evolu.subscribeQuery(todoTitleQuery)(constVoid);
1907
+ await testWaitForWorkerMessage();
1908
+ assertEqual(evoluInputs, [
1909
+ { type: "Query", queries: new Set([todoTitleQuery]) },
1910
+ ]);
1911
+ unsubscribe();
1912
+ evoluInputs.length = 0;
1913
+
1914
+ // The refresh response still replaces the cached rows.
1915
+ postEvoluOutput({
1916
+ type: "OnPatchesByQuery",
1917
+ patchesByQuery: new Map([
1918
+ [
1919
+ todoTitleQuery,
1920
+ [{ op: "replaceAll", value: [{ title: "Synced" }] }],
1921
+ ],
1922
+ ]),
1923
+ onCompleteIds: [],
1924
+ });
1925
+ await testWaitForWorkerMessage();
1926
+ assertEqual(evolu.getQueryRows(todoTitleQuery), [{ title: "Synced" }]);
1927
+ const cachedLoad = evolu.loadQuery(todoTitleQuery);
1928
+ assertEqual(await cachedLoad, [{ title: "Synced" }]);
1929
+ await testWaitForWorkerMessage();
1930
+ assertEqual(evoluInputs, []);
1931
+
1932
+ // The next invalidation drops the unsubscribed cache without a read.
1933
+ postEvoluOutput({ type: "RefreshQueries" });
1934
+ await testWaitForWorkerMessage();
1935
+ assertEqual(evoluInputs, []);
1936
+ const reload = evolu.loadQuery(todoTitleQuery);
1937
+ assertNotSame(reload, cachedLoad);
1938
+ await testWaitForWorkerMessage();
1939
+ assertEqual(evoluInputs, [
1940
+ { type: "Query", queries: new Set([todoTitleQuery]) },
1941
+ ]);
1942
+ });
1943
+
1944
+ it("subscribeQuery reuses the refreshing entry for repeated subscriptions in one tick", async () => {
1945
+ await using setup = await setupRunWithEvoluDeps();
1946
+ const { run, evoluInputs, postEvoluOutput } = setup;
1947
+ const evolu = await run.ok(testCreateEvolu);
1948
+
1949
+ const initialLoad = evolu.loadQuery(todoTitleQuery);
1950
+ await testWaitForWorkerMessage();
1951
+ postEvoluOutput({
1952
+ type: "OnPatchesByQuery",
1953
+ patchesByQuery: new Map([
1954
+ [todoTitleQuery, [{ op: "replaceAll", value: [] }]],
1955
+ ]),
1956
+ onCompleteIds: [],
1957
+ });
1958
+ await initialLoad;
1959
+ postEvoluOutput({ type: "RefreshQueries" });
1960
+ await testWaitForWorkerMessage();
1961
+ evoluInputs.length = 0;
1962
+
1963
+ // React StrictMode subscribes, unsubscribes, and subscribes again
1964
+ // synchronously.
1965
+ const unsubscribeFirst = evolu.subscribeQuery(todoTitleQuery)(constVoid);
1966
+ const refreshingLoad = evolu.loadQuery(todoTitleQuery);
1967
+ unsubscribeFirst();
1968
+ const unsubscribe = evolu.subscribeQuery(todoTitleQuery)(constVoid);
1969
+ assertSame(evolu.loadQuery(todoTitleQuery), refreshingLoad);
1970
+ await testWaitForWorkerMessage();
1971
+ assertEqual(evoluInputs, [
1972
+ { type: "Query", queries: new Set([todoTitleQuery]) },
1973
+ ]);
1974
+ unsubscribe();
1975
+ });
1976
+
1318
1977
  it("loadQuery reuses pending promise and sends one Query message", async () => {
1319
1978
  await using setup = await setupRunWithEvoluDeps();
1320
1979
  const { run, evoluInputs } = setup;
@@ -1399,7 +2058,7 @@ describe("Evolu", () => {
1399
2058
  );
1400
2059
  });
1401
2060
 
1402
- it("RefreshQueries re-queries pending unsubscribed loadQuery", async () => {
2061
+ it("RefreshQueries reads a pending unsubscribed loadQuery once, when it is subscribed", async () => {
1403
2062
  await using setup = await setupRunWithEvoluDeps();
1404
2063
  const { run, evoluInputs, postEvoluOutput } = setup;
1405
2064
  const evolu = await run.ok(testCreateEvolu);
@@ -1409,12 +2068,18 @@ describe("Evolu", () => {
1409
2068
 
1410
2069
  evoluInputs.length = 0;
1411
2070
  postEvoluOutput({ type: "RefreshQueries" });
2071
+ await testWaitForWorkerMessage();
1412
2072
 
2073
+ assertEqual(evoluInputs, []);
2074
+
2075
+ const unsubscribe = evolu.subscribeQuery(todoTitleQuery)(constVoid);
1413
2076
  await testWaitForWorkerMessage();
1414
2077
 
1415
2078
  assertEqual(evoluInputs, [
1416
2079
  { type: "Query", queries: new Set([todoTitleQuery]) },
1417
2080
  ]);
2081
+
2082
+ unsubscribe();
1418
2083
  });
1419
2084
 
1420
2085
  it("RefreshQueries re-queries subscribed query without loadQuery", async () => {