@evolu/web 3.1.1 → 3.2.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.
@@ -6,8 +6,9 @@ import {
6
6
  PositiveInt,
7
7
  testStubGlobal,
8
8
  } from "@evolu/common";
9
- import { test } from "node:test";
9
+ import { mock, test } from "node:test";
10
10
  import { availableParallelism, isApplePlatform } from "./index.ts";
11
+ import { reloadApp } from "./Platform.ts";
11
12
 
12
13
  for (const platform of ["macOS", "MacIntel", "iPhone", "iPad", "iPod"]) {
13
14
  test(`isApplePlatform recognizes ${platform}`, () => {
@@ -43,3 +44,30 @@ test("availableParallelism returns the validated browser value", () => {
43
44
  assertType<typeof parallelism, PositiveInt>();
44
45
  assertEqual(parallelism, 128);
45
46
  });
47
+
48
+ test("reloadApp reloads the current page", () => {
49
+ const reload = mock.fn<() => void>();
50
+ const replace = mock.fn<(url: string) => void>();
51
+ using _document = testStubGlobal("document", {});
52
+ using _location = testStubGlobal("location", { reload, replace });
53
+
54
+ reloadApp();
55
+
56
+ assertEqual(reload.mock.callCount(), 1);
57
+ assertEqual(replace.mock.callCount(), 0);
58
+ });
59
+
60
+ test("reloadApp loads a given URL instead", () => {
61
+ const reload = mock.fn<() => void>();
62
+ const replace = mock.fn<(url: string) => void>();
63
+ using _document = testStubGlobal("document", {});
64
+ using _location = testStubGlobal("location", { reload, replace });
65
+
66
+ reloadApp("/signed-out");
67
+
68
+ assertEqual(
69
+ replace.mock.calls.map((call) => call.arguments),
70
+ [["/signed-out"]],
71
+ );
72
+ assertEqual(reload.mock.callCount(), 0);
73
+ });
package/src/Platform.ts CHANGED
@@ -16,5 +16,6 @@ export const reloadApp: ReloadApp = (url) => {
16
16
  return;
17
17
  }
18
18
 
19
- location.replace(url ?? "/");
19
+ if (url === undefined) location.reload();
20
+ else location.replace(url);
20
21
  };
@@ -1,7 +1,11 @@
1
1
  import {
2
+ AbortError,
3
+ assert,
2
4
  assertEqual,
3
5
  assertEqualBytes,
6
+ assertSame,
4
7
  assertTrue,
8
+ assertType,
5
9
  EncryptionKey,
6
10
  Name,
7
11
  sql,
@@ -9,6 +13,90 @@ import {
9
13
  } from "@evolu/common";
10
14
  import { describe, it, mock } from "node:test";
11
15
 
16
+ // OPFS with an optional pool directory. Each pool file lists the error names
17
+ // its next openings reject with, in order.
18
+ const opfsMock = (() => {
19
+ const state = {
20
+ closedAccessHandleCount: 0,
21
+ directoryNames: [] as Array<string>,
22
+ getDirectoryCount: 0,
23
+ hasOpaqueDirectory: true,
24
+ openedAccessHandleCount: 0,
25
+ poolFiles: null as Array<Array<string>> | null,
26
+ };
27
+
28
+ const opaqueDirectory = {
29
+ // for await also iterates a sync iterable.
30
+ values: function* () {
31
+ yield { kind: "directory" };
32
+ for (const errorNames of state.poolFiles ?? [])
33
+ yield {
34
+ kind: "file",
35
+ createSyncAccessHandle: () => {
36
+ const errorName = errorNames.shift();
37
+ if (errorName !== undefined)
38
+ return Promise.reject(new DOMException("Held", errorName));
39
+ state.openedAccessHandleCount += 1;
40
+ return Promise.resolve({
41
+ close: () => {
42
+ state.closedAccessHandleCount += 1;
43
+ },
44
+ });
45
+ },
46
+ };
47
+ },
48
+ };
49
+
50
+ const poolDirectory = {
51
+ getDirectoryHandle: (name: string) => {
52
+ state.directoryNames.push(name);
53
+ return state.hasOpaqueDirectory
54
+ ? Promise.resolve(opaqueDirectory)
55
+ : Promise.reject(new DOMException("Missing", "NotFoundError"));
56
+ },
57
+ };
58
+
59
+ const root = {
60
+ getDirectoryHandle: (name: string) => {
61
+ state.directoryNames.push(name);
62
+ return state.poolFiles
63
+ ? Promise.resolve(poolDirectory)
64
+ : Promise.reject(new DOMException("Missing", "NotFoundError"));
65
+ },
66
+ };
67
+
68
+ Object.defineProperty(navigator, "storage", {
69
+ configurable: true,
70
+ value: {
71
+ getDirectory: () => {
72
+ state.getDirectoryCount += 1;
73
+ return Promise.resolve(root);
74
+ },
75
+ },
76
+ });
77
+
78
+ return {
79
+ reset: (
80
+ poolFiles: Array<Array<string>> | null = null,
81
+ { hasOpaqueDirectory = true } = {},
82
+ ) => {
83
+ state.closedAccessHandleCount = 0;
84
+ state.directoryNames.length = 0;
85
+ state.getDirectoryCount = 0;
86
+ state.hasOpaqueDirectory = hasOpaqueDirectory;
87
+ state.openedAccessHandleCount = 0;
88
+ state.poolFiles = poolFiles;
89
+ },
90
+ state,
91
+ };
92
+ })();
93
+
94
+ // Lets resolved mocks settle before test time advances.
95
+ const flushMicrotasks = (): Promise<void> =>
96
+ new Promise((resolve) => {
97
+ setImmediate(resolve);
98
+ });
99
+
12
100
  const sqliteMock = (() => {
13
101
  class PreparedStatement {
14
102
  finalized = false;
@@ -115,6 +203,7 @@ const sqliteMock = (() => {
115
203
  consoleWarn: mock.fn<typeof console.warn>(),
116
204
  pool,
117
205
  reset: () => {
206
+ opfsMock.reset();
118
207
  state.closedDatabases.length = 0;
119
208
  state.createdDatabases.length = 0;
120
209
  state.deletedFilenames.length = 0;
@@ -193,6 +282,7 @@ describe("createWasmSqliteDriver coverage helpers", () => {
193
282
 
194
283
  assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
195
284
  assertEqual(sqliteMock.state.createdDatabases[0]?.filename, ":memory:");
285
+ assertEqual(opfsMock.state.getDirectoryCount, 0);
196
286
  });
197
287
 
198
288
  it("executes non-prepared query and exports database", async () => {
@@ -354,3 +444,89 @@ describe("createWasmSqliteDriver coverage helpers", () => {
354
444
  );
355
445
  });
356
446
  });
447
+
448
+ describe("createWasmSqliteDriver held pool files", () => {
449
+ for (const errorName of ["InvalidStateError", "NoModificationAllowedError"])
450
+ it(`sets up the pool once a file held with ${errorName} is released`, async () => {
451
+ sqliteMock.reset();
452
+ opfsMock.reset([[], [errorName, errorName]]);
453
+
454
+ await using run = testCreateRun();
455
+ const driver = run.ok(createWasmSqliteDriver(Name.orThrow("MockPlain")));
456
+ await flushMicrotasks();
457
+
458
+ assertEqual(opfsMock.state.directoryNames, [".MockPlain", ".opaque"]);
459
+ run.deps.time.advance("49ms");
460
+ await flushMicrotasks();
461
+ assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
462
+
463
+ // The retry delay doubles after each held pass.
464
+ run.deps.time.advance("1ms");
465
+ await flushMicrotasks();
466
+ run.deps.time.advance("99ms");
467
+ await flushMicrotasks();
468
+ assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
469
+
470
+ run.deps.time.advance("1ms");
471
+ using _driver = await driver;
472
+
473
+ // The free file opens on each of the three passes, the held one once.
474
+ assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 1);
475
+ assertEqual(opfsMock.state.openedAccessHandleCount, 4);
476
+ assertEqual(opfsMock.state.closedAccessHandleCount, 4);
477
+ });
478
+
479
+ it("sets up the pool at once when its directory has no files yet", async () => {
480
+ sqliteMock.reset();
481
+ opfsMock.reset([], { hasOpaqueDirectory: false });
482
+
483
+ await using run = testCreateRun();
484
+ using _driver = await run.ok(
485
+ createWasmSqliteDriver(Name.orThrow("MockPlain")),
486
+ );
487
+
488
+ assertEqual(opfsMock.state.directoryNames, [".MockPlain", ".opaque"]);
489
+ assertEqual(opfsMock.state.openedAccessHandleCount, 0);
490
+ assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 1);
491
+ });
492
+
493
+ it("warns once when pool files stay held for five seconds", async () => {
494
+ sqliteMock.reset();
495
+ opfsMock.reset([Array.from({ length: 12 }, () => "InvalidStateError")]);
496
+
497
+ await using run = testCreateRun();
498
+ const driver = run.ok(createWasmSqliteDriver(Name.orThrow("MockPlain")));
499
+ for (let second = 0; second < 12; second += 1) {
500
+ await flushMicrotasks();
501
+ run.deps.time.advance("1s");
502
+ }
503
+ using _driver = await driver;
504
+
505
+ const warnings = run.deps.console
506
+ .getEntriesSnapshot()
507
+ .filter((entry) => entry.method === "warn");
508
+ assertEqual(
509
+ warnings.map((entry) => entry.args),
510
+ [
511
+ [
512
+ "Waiting for an ended DbWorker to release the files of database MockPlain.",
513
+ ],
514
+ ],
515
+ );
516
+ });
517
+
518
+ it("fails before setting up the pool when a file cannot be opened", async () => {
519
+ sqliteMock.reset();
520
+ opfsMock.reset([["UnknownError"]]);
521
+
522
+ await using run = testCreateRun();
523
+ const result = await run.abortable(
524
+ createWasmSqliteDriver(Name.orThrow("MockPlain")),
525
+ );
526
+
527
+ assert(!result.ok, "Expected the driver to fail.");
528
+ assertType(AbortError, result.error);
529
+ assertSame(result.error.reason.type, "PanicAbortReason");
530
+ assertEqual(sqliteMock.sqlite3.installOpfsSAHPoolVfs.mock.callCount(), 0);
531
+ });
532
+ });
package/src/Sqlite.ts CHANGED
@@ -4,6 +4,10 @@ import {
4
4
  createPreparedStatementsCache,
5
5
  exhaustiveCheck,
6
6
  ok,
7
+ performanceDurationBetween,
8
+ PositiveMillis,
9
+ sleep,
10
+ tryAsync,
7
11
  } from "@evolu/common";
8
12
  import sqlite3InitModule, {
9
13
  type Database,
@@ -32,7 +36,7 @@ const sqlite3Promise = sqlite3InitModule();
32
36
  const fileName = "evolu1.db";
33
37
 
34
38
  export const createWasmSqliteDriver: CreateSqliteDriver =
35
- (name, options) => async () => {
39
+ (name, options) => async (run) => {
36
40
  const sqlite3 = await sqlite3Promise;
37
41
 
38
42
  using disposer = new DisposableStack();
@@ -45,6 +49,69 @@ export const createWasmSqliteDriver: CreateSqliteDriver =
45
49
  const createOpfsSAHPoolVfs = async (
46
50
  options: Parameters<typeof sqlite3.installOpfsSAHPoolVfs>[0],
47
51
  ): Promise<SAHPoolUtil> => {
52
+ // Evolu opens a database only while it holds the database lock, but a
53
+ // DbWorker that ended without closing it, because its tab closed,
54
+ // crashed or navigated away, can hold pool files after the lock has
55
+ // passed on: WebKit releases a terminated worker's locks and its files
56
+ // separately, in no set order. sqlite-wasm cannot set up a pool with a
57
+ // held file, and it then deletes the pool directory, which the held file
58
+ // usually but not always prevents. Only a worker that has ended can hold
59
+ // the files, and it can only release them, so the pool is set up once
60
+ // every file opens.
61
+ const canOpenPoolFiles = async (): Promise<boolean> => {
62
+ const root = await navigator.storage.getDirectory();
63
+ // sqlite-wasm keeps the pool in `.${name}/.opaque` in both OPFS modes.
64
+ const poolDirectory = await tryAsync(() =>
65
+ root.getDirectoryHandle(`.${name}`),
66
+ );
67
+ if (!poolDirectory.ok) return true;
68
+ const opaqueDirectory = await tryAsync(() =>
69
+ poolDirectory.value.getDirectoryHandle(".opaque"),
70
+ );
71
+ if (!opaqueDirectory.ok) return true;
72
+ for await (const handle of opaqueDirectory.value.values()) {
73
+ if (handle.kind !== "file") continue;
74
+ const accessHandle = await tryAsync(() =>
75
+ handle.createSyncAccessHandle(),
76
+ );
77
+ if (!accessHandle.ok) {
78
+ // WebKit rejects a held file with InvalidStateError, other
79
+ // engines with NoModificationAllowedError, as the spec says.
80
+ // WebKit also uses InvalidStateError for a closed or invalid
81
+ // handle and a stopped context. A retry opens fresh handles from
82
+ // a new listing, and a stopped context ends the loop with its
83
+ // worker, so retrying those is harmless.
84
+ if (
85
+ accessHandle.error instanceof DOMException &&
86
+ (accessHandle.error.name === "InvalidStateError" ||
87
+ accessHandle.error.name === "NoModificationAllowedError")
88
+ )
89
+ return false;
90
+ throw accessHandle.error;
91
+ }
92
+ accessHandle.value.close();
93
+ }
94
+ return true;
95
+ };
96
+
97
+ const waitStart = run.deps.time.performance.now();
98
+ let retryDelay = 50;
99
+ let isWaitReported = false;
100
+ while (!(await canOpenPoolFiles())) {
101
+ const waited = performanceDurationBetween(
102
+ waitStart,
103
+ run.deps.time.performance.now(),
104
+ );
105
+ if (!isWaitReported && waited >= 5000) {
106
+ isWaitReported = true;
107
+ run.deps.console.warn(
108
+ `Waiting for an ended DbWorker to release the files of database ${name}.`,
109
+ );
110
+ }
111
+ await run.ok(sleep(PositiveMillis.orThrow(retryDelay)));
112
+ retryDelay = Math.min(retryDelay * 2, 1000);
113
+ }
114
+
48
115
  const pool = await sqlite3.installOpfsSAHPoolVfs(options);
49
116
  if (pool.isPaused()) await pool.unpauseVfs();
50
117
  disposer.defer(() => {
package/src/Task.test.ts CHANGED
@@ -1,14 +1,9 @@
1
- import {
2
- assertEqual,
3
- assertNonNullable,
4
- assertTrue,
5
- testStubGlobal,
6
- } from "@evolu/common";
1
+ import { assertEqual, assertSame, testStubGlobal } from "@evolu/common";
7
2
  import { describe, it, mock } from "node:test";
8
3
  import { createRun } from "./Task.ts";
9
4
 
10
5
  describe("createRun", () => {
11
- it("createRun reports defects with global reportError", async () => {
6
+ it("createRun reports a panic's defect with global reportError", async () => {
12
7
  const reportError = mock.fn<(error: unknown) => void>();
13
8
  using _reportError = testStubGlobal("reportError", reportError);
14
9
  await using run = createRun();
@@ -17,13 +12,7 @@ describe("createRun", () => {
17
12
  run.panic(defect);
18
13
 
19
14
  assertEqual(reportError.mock.callCount(), 1);
20
- const reported = reportError.mock.calls[0]?.arguments[0];
21
- assertNonNullable(reported);
22
- assertTrue(typeof reported === "object");
23
- assertEqual(Reflect.get(reported, "reason"), {
24
- type: "PanicAbortReason",
25
- defect,
26
- });
15
+ assertSame(reportError.mock.calls[0]?.arguments[0], defect);
27
16
  });
28
17
 
29
18
  it("createRun preserves a custom reportDefect", async () => {
package/src/Task.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import {
8
8
  createRun as createCommonRun,
9
+ defectToError,
9
10
  type DisposableRun,
10
11
  type Run,
11
12
  type RunCustomDeps,
@@ -14,13 +15,16 @@ import {
14
15
  /**
15
16
  * Creates a root {@link Run} for the browser.
16
17
  *
17
- * Defects are reported with the browser's global `reportError`. A custom
18
- * `reportDefect` dependency overrides the browser default.
18
+ * Defects are reported with the browser's global `reportError` as an `Error`
19
+ * from {@link defectToError}, because browsers show any other value only as
20
+ * unhelpful text. A custom `reportDefect` dependency overrides the browser
21
+ * default.
19
22
  *
20
23
  * ### Example
21
24
  *
22
25
  * ```ts
23
26
  * import {
27
+ * assertEqual,
24
28
  * createConsole,
25
29
  * createConsoleFormatter,
26
30
  * ok,
@@ -36,7 +40,7 @@ import {
36
40
  * await using run = createRun({ console });
37
41
  * const appPromise = run.ok(() => ok("started"));
38
42
  *
39
- * expect(await appPromise).toBe("started");
43
+ * assertEqual(await appPromise, "started");
40
44
  * ```
41
45
  */
42
46
  export function createRun(): DisposableRun;
@@ -49,7 +53,7 @@ export function createRun<D extends object>(
49
53
  deps?: RunCustomDeps<D>,
50
54
  ): DisposableRun | DisposableRun<D> {
51
55
  const reportDefect = (reported: unknown): void => {
52
- reportError(reported);
56
+ reportError(defectToError(reported));
53
57
  };
54
58
 
55
59
  return deps === undefined