@evolu/web 3.4.0 → 3.5.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.
package/README.md CHANGED
@@ -4,6 +4,10 @@ This package provides browser-specific Evolu implementations, including
4
4
  OPFS-backed SQLite, Web Workers, Shared Workers, and browser platform
5
5
  dependencies.
6
6
 
7
+ SQLite comes from `@evolu/sqlite-wasm`. Bundlers emit its WebAssembly binary
8
+ with the app. Vite does not emit it from a dependency it prebundles, so add
9
+ `@evolu/sqlite-wasm` to `optimizeDeps.exclude`.
10
+
7
11
  As runtimes increasingly implement the same Web Platform APIs, Evolu's portable
8
12
  abstractions for `WebSocket`, `MessagePort`, and Web Locks live in
9
13
  `@evolu/common`. Their concrete implementations are supplied by the runtime.
@@ -1,3 +1,72 @@
1
- import type { CreateSqliteDriver } from "@evolu/common";
2
- export declare const createWasmSqliteDriver: CreateSqliteDriver;
1
+ /**
2
+ * The SQLite driver of Evolu for the web, on `@evolu/sqlite-wasm`.
3
+ *
4
+ * @module
5
+ */
6
+ import { type CreateSqliteDriver, type Result, type Task } from "@evolu/common";
7
+ import type { WaitForDatabaseRelease } from "@evolu/common/local-first";
8
+ import { type OpfsRootDep, type SqliteWasm, type SqliteWasmError, type SubtleCryptoDep } from "@evolu/sqlite-wasm";
9
+ /**
10
+ * Loads SQLite from the {@link sqliteWasmUrl} of `@evolu/sqlite-wasm` for
11
+ * {@link createWasmSqliteDriver}. It passes the fetch to
12
+ * {@link createSqliteWasm}, which compiles the binary while it downloads when
13
+ * the server sends it as `application/wasm`, and from its bytes otherwise.
14
+ *
15
+ * Start it in the worker's composition root, so SQLite loads while the worker
16
+ * waits for its database.
17
+ */
18
+ export declare const loadSqliteWasm: Task<SqliteWasm, SqliteWasmError>;
19
+ /**
20
+ * Creates the {@link CreateSqliteDriver} of Evolu for the web.
21
+ *
22
+ * A database is the file `/evolu1.db` in a pool of OPFS sync access handles in
23
+ * the directory `.<name>`, in the format of SQLite's opfs-sahpool, and the pool
24
+ * encrypts it in the `encrypted` mode. So the databases `@evolu/web` 3 created
25
+ * with `@evolu/sqlite-wasm` 2.2.4 open unchanged, an encrypted one with the key
26
+ * 2.2.4 derived from the encryption key, which is never rekeyed. 2.2.4 derived
27
+ * it because of a bug in SQLite3 Multiple Ciphers: it took the encryption key,
28
+ * passed in SQLCipher's notation for a raw key, as a passphrase
29
+ * (https://github.com/utelle/SQLite3MultipleCiphers/issues/218), as
30
+ * {@link deriveLegacySqliteKey} of `@evolu/sqlite-wasm` describes. A new
31
+ * encrypted database is encrypted with the encryption key itself, so
32
+ * `@evolu/web` 3.4.1 and earlier cannot open it. A database in the `memory`
33
+ * mode uses no OPFS.
34
+ *
35
+ * In WebKit on macOS, such as Safari, whose file system ignores case by
36
+ * default, names that differ only in case share the directory, and both can
37
+ * hold it at once, so make database names differ in more than case.
38
+ *
39
+ * The pool is opened once, so the driver throws while another context holds a
40
+ * file of it. A DbWorker waits for the files with
41
+ * {@link createWaitForDatabaseRelease} first.
42
+ *
43
+ * The {@link SqliteDriver} contract has no error channel, so every error, such
44
+ * as a query's {@link SqliteError}, is thrown as the cause of an `Error` whose
45
+ * message is SQLite's message, or the error's type for an error without one.
46
+ */
47
+ export declare const createWasmSqliteDriver: (deps: WasmSqliteDriverDeps) => CreateSqliteDriver;
48
+ /** Dependencies of {@link createWasmSqliteDriver}. */
49
+ export type WasmSqliteDriverDeps = OpfsRootDep & SqliteWasmLoadDep & SubtleCryptoDep;
50
+ /** Dependency wrapper for SQLite as {@link loadSqliteWasm} loads it. */
51
+ export interface SqliteWasmLoadDep {
52
+ /**
53
+ * SQLite, as {@link loadSqliteWasm} loads it, still loading or loaded. A
54
+ * failed load throws when a database is opened.
55
+ */
56
+ readonly sqliteWasmLoad: PromiseLike<Result<SqliteWasm, SqliteWasmError>>;
57
+ }
58
+ /**
59
+ * Creates the {@link WaitForDatabaseRelease} of Evolu for the web. It opens the
60
+ * pool {@link createWasmSqliteDriver} opens for the database, and disposes it
61
+ * once it opens.
62
+ *
63
+ * When another context holds a file of the pool, the pool is opened again after
64
+ * 50 ms, then after twice the previous delay, up to a second, with the
65
+ * `heldTimeout` option of {@link openSahPool}, and the wait fails with
66
+ * {@link DatabaseHeldError} when it is still held 10 seconds after the first
67
+ * attempt started. Evolu then refuses to start the database, and the app
68
+ * receives the error as its `evoluError`. Every other error is thrown, as the
69
+ * driver throws it.
70
+ */
71
+ export declare const createWaitForDatabaseRelease: (deps: OpfsRootDep & SqliteWasmLoadDep) => WaitForDatabaseRelease;
3
72
  //# sourceMappingURL=Sqlite.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Sqlite.d.ts","sourceRoot":"","sources":["../../src/Sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAa,MAAM,eAAe,CAAC;AAqCnE,eAAO,MAAM,sBAAsB,EAAE,kBAyLlC,CAAC"}
1
+ {"version":3,"file":"Sqlite.d.ts","sourceRoot":"","sources":["../../src/Sqlite.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAKL,KAAK,kBAAkB,EAGvB,KAAK,MAAM,EAEX,KAAK,IAAI,EAGV,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,2BAA2B,CAAC;AACxE,OAAO,EASL,KAAK,WAAW,EAOhB,KAAK,UAAU,EAEf,KAAK,eAAe,EACpB,KAAK,eAAe,EACrB,MAAM,oBAAoB,CAAC;AAE5B;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,EAAE,IAAI,CAAC,UAAU,EAAE,eAAe,CACS,CAAC;AAEvE;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,eAAO,MAAM,sBAAsB,SAC1B,oBAAoB,KAAG,kBA+F7B,CAAC;AAEJ,sDAAsD;AACtD,MAAM,MAAM,oBAAoB,GAAG,WAAW,GAC5C,iBAAiB,GACjB,eAAe,CAAC;AAElB,wEAAwE;AACxE,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,QAAQ,CAAC,cAAc,EAAE,WAAW,CAAC,MAAM,CAAC,UAAU,EAAE,eAAe,CAAC,CAAC,CAAC;CAC3E;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,4BAA4B,SAChC,WAAW,GAAG,iBAAiB,KAAG,sBAaxC,CAAC"}
@@ -1,3 +1,8 @@
1
+ /**
2
+ * The SQLite driver of Evolu for the web, on `@evolu/sqlite-wasm`.
3
+ *
4
+ * @module
5
+ */
1
6
  var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
7
  if (value !== null && value !== void 0) {
3
8
  if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
@@ -50,163 +55,98 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
50
55
  var e = new Error(message);
51
56
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
57
  });
53
- import { bytesToHex, createPreparedStatementsCache, exhaustiveCheck, ok, performanceDurationBetween, PositiveMillis, sleep, tryAsync, } from "@evolu/common";
54
- import sqlite3InitModule, {} from "@evolu/sqlite-wasm";
55
- // @ts-expect-error Missing types.
56
- globalThis.sqlite3ApiConfig = {
57
- warn: (arg) => {
58
- // Ignore irrelevant warning.
59
- // https://github.com/sqlite/sqlite-wasm/issues/62
60
- if (typeof arg === "string" &&
61
- arg.startsWith("Ignoring inability to install OPFS sqlite3_vfs"))
62
- return;
63
- // oxlint-disable-next-line eslint/no-console
64
- console.warn(arg);
65
- },
66
- };
67
- // Init ASAP.
68
- const sqlite3Promise = sqlite3InitModule();
69
- const fileName = "evolu1.db";
70
- export const createWasmSqliteDriver = (name, options) => async (run) => {
58
+ import { createPreparedStatementsCache, err, exhaustiveCheck, ok, } from "@evolu/common";
59
+ import { createEncryptedSqliteDatabase, createSqliteDatabase, createSqliteWasm, OpfsName, openSahPool, sqliteWasmUrl, SqliteVfsPath, } from "@evolu/sqlite-wasm";
60
+ /**
61
+ * Loads SQLite from the {@link sqliteWasmUrl} of `@evolu/sqlite-wasm` for
62
+ * {@link createWasmSqliteDriver}. It passes the fetch to
63
+ * {@link createSqliteWasm}, which compiles the binary while it downloads when
64
+ * the server sends it as `application/wasm`, and from its bytes otherwise.
65
+ *
66
+ * Start it in the worker's composition root, so SQLite loads while the worker
67
+ * waits for its database.
68
+ */
69
+ export const loadSqliteWasm = (run) => run(createSqliteWasm(run.deps.nativeFetch(sqliteWasmUrl)), run.deps);
70
+ /**
71
+ * Creates the {@link CreateSqliteDriver} of Evolu for the web.
72
+ *
73
+ * A database is the file `/evolu1.db` in a pool of OPFS sync access handles in
74
+ * the directory `.<name>`, in the format of SQLite's opfs-sahpool, and the pool
75
+ * encrypts it in the `encrypted` mode. So the databases `@evolu/web` 3 created
76
+ * with `@evolu/sqlite-wasm` 2.2.4 open unchanged, an encrypted one with the key
77
+ * 2.2.4 derived from the encryption key, which is never rekeyed. 2.2.4 derived
78
+ * it because of a bug in SQLite3 Multiple Ciphers: it took the encryption key,
79
+ * passed in SQLCipher's notation for a raw key, as a passphrase
80
+ * (https://github.com/utelle/SQLite3MultipleCiphers/issues/218), as
81
+ * {@link deriveLegacySqliteKey} of `@evolu/sqlite-wasm` describes. A new
82
+ * encrypted database is encrypted with the encryption key itself, so
83
+ * `@evolu/web` 3.4.1 and earlier cannot open it. A database in the `memory`
84
+ * mode uses no OPFS.
85
+ *
86
+ * In WebKit on macOS, such as Safari, whose file system ignores case by
87
+ * default, names that differ only in case share the directory, and both can
88
+ * hold it at once, so make database names differ in more than case.
89
+ *
90
+ * The pool is opened once, so the driver throws while another context holds a
91
+ * file of it. A DbWorker waits for the files with
92
+ * {@link createWaitForDatabaseRelease} first.
93
+ *
94
+ * The {@link SqliteDriver} contract has no error channel, so every error, such
95
+ * as a query's {@link SqliteError}, is thrown as the cause of an `Error` whose
96
+ * message is SQLite's message, or the error's type for an error without one.
97
+ */
98
+ export const createWasmSqliteDriver = (deps) => (name, options) => async (run) => {
71
99
  const env_1 = { stack: [], error: void 0, hasError: false };
72
100
  try {
73
- const sqlite3 = await sqlite3Promise;
74
- const disposer = __addDisposableResource(env_1, new DisposableStack(), false);
75
- const useDatabase = (database) => disposer.adopt(database, (database) => {
76
- database.close();
77
- });
101
+ const sqliteWasm = getOrThrowWithMessage(await deps.sqliteWasmLoad);
102
+ const databaseDeps = { ...deps, sqliteWasm };
78
103
  let deleteDatabaseFile = false;
79
- const createOpfsSAHPoolVfs = async (options) => {
80
- // Evolu opens a database only while it holds the database lock, but a
81
- // DbWorker that ended without closing it, because its tab closed,
82
- // crashed or navigated away, can hold pool files after the lock has
83
- // passed on: WebKit releases a terminated worker's locks and its files
84
- // separately, in no set order. sqlite-wasm cannot set up a pool with a
85
- // held file, and it then deletes the pool directory, which the held file
86
- // usually but not always prevents. Only a worker that has ended can hold
87
- // the files, and it can only release them, so the pool is set up once
88
- // every file opens.
89
- const canOpenPoolFiles = async () => {
90
- const root = await navigator.storage.getDirectory();
91
- // sqlite-wasm keeps the pool in `.${name}/.opaque` in both OPFS modes.
92
- const poolDirectory = await tryAsync(() => root.getDirectoryHandle(`.${name}`));
93
- if (!poolDirectory.ok)
94
- return true;
95
- const opaqueDirectory = await tryAsync(() => poolDirectory.value.getDirectoryHandle(".opaque"));
96
- if (!opaqueDirectory.ok)
97
- return true;
98
- for await (const handle of opaqueDirectory.value.values()) {
99
- if (handle.kind !== "file")
100
- continue;
101
- const accessHandle = await tryAsync(() => handle.createSyncAccessHandle());
102
- if (!accessHandle.ok) {
103
- // WebKit rejects a held file with InvalidStateError, other
104
- // engines with NoModificationAllowedError, as the spec says.
105
- // WebKit also uses InvalidStateError for a closed or invalid
106
- // handle and a stopped context. A retry opens fresh handles from
107
- // a new listing, and a stopped context ends the loop with its
108
- // worker, so retrying those is harmless.
109
- if (accessHandle.error instanceof DOMException &&
110
- (accessHandle.error.name === "InvalidStateError" ||
111
- accessHandle.error.name === "NoModificationAllowedError"))
112
- return false;
113
- throw accessHandle.error;
114
- }
115
- accessHandle.value.close();
116
- }
117
- return true;
118
- };
119
- const waitStart = run.deps.time.performance.now();
120
- let retryDelay = 50;
121
- let isWaitReported = false;
122
- while (!(await canOpenPoolFiles())) {
123
- const waited = performanceDurationBetween(waitStart, run.deps.time.performance.now());
124
- if (!isWaitReported && waited >= 5000) {
125
- isWaitReported = true;
126
- run.deps.console.warn(`Waiting for an ended DbWorker to release the files of database ${name}.`);
127
- }
128
- await run.ok(sleep(PositiveMillis.orThrow(retryDelay)));
129
- retryDelay = Math.min(retryDelay * 2, 1000);
130
- }
131
- const pool = await sqlite3.installOpfsSAHPoolVfs(options);
132
- if (pool.isPaused())
133
- await pool.unpauseVfs();
104
+ const disposer = __addDisposableResource(env_1, new DisposableStack(), false);
105
+ const openPool = async () => {
106
+ const pool = disposer.use(getOrThrowWithMessage(await run(openDatabasePool(name), databaseDeps)));
134
107
  disposer.defer(() => {
135
108
  if (deleteDatabaseFile)
136
- pool.unlink(`/${fileName}`);
137
- pool.pauseVfs();
109
+ getOrThrowWithMessage(pool.unlink(databasePath));
138
110
  });
139
111
  return pool;
140
112
  };
141
- let db;
113
+ let database;
142
114
  switch (options?.mode) {
143
115
  case "memory":
144
- // oxlint-disable-next-line react/rules-of-hooks -- useDatabase registers disposal and is not a React Hook.
145
- db = useDatabase(new sqlite3.oo1.DB(":memory:"));
116
+ database = disposer.use(getOrThrowWithMessage(createSqliteDatabase(databaseDeps)({ type: "Memory" })));
146
117
  break;
147
118
  case "encrypted": {
148
- // MultipleCiphers encryption requires its VFS wrapper for OPFS SAH-pool.
149
- // @ts-expect-error Missing types (update @evolu/sqlite-wasm types)
150
- // oxlint-disable-next-line typescript/no-unsafe-call
151
- sqlite3.capi.sqlite3mc_vfs_create("opfs", 1);
152
- const pool = await createOpfsSAHPoolVfs({
153
- directory: `.${name}`,
154
- });
155
- // oxlint-disable-next-line react/rules-of-hooks -- useDatabase registers disposal and is not a React Hook.
156
- db = useDatabase(new pool.OpfsSAHPoolDb(
157
- // SQLite normalizes this URI filename to SAH-pool path "/evolu1.db".
158
- `file:${fileName}?vfs=multipleciphers-opfs-sahpool`));
159
- db.exec(`
160
- PRAGMA cipher = 'sqlcipher';
161
- PRAGMA key = "x'${bytesToHex(options.encryptionKey)}'";
162
- `);
119
+ const encrypted = getOrThrowWithMessage(await run(createEncryptedSqliteDatabase({
120
+ type: "EncryptedFile",
121
+ vfs: await openPool(),
122
+ path: databasePath,
123
+ key: options.encryptionKey,
124
+ }), databaseDeps));
125
+ database = disposer.use(encrypted.database);
163
126
  break;
164
127
  }
165
- case undefined: {
166
- const pool = await createOpfsSAHPoolVfs({ name });
167
- // oxlint-disable-next-line react/rules-of-hooks -- useDatabase registers disposal and is not a React Hook.
168
- db = useDatabase(new pool.OpfsSAHPoolDb(`file:${fileName}`));
128
+ case undefined:
129
+ database = disposer.use(getOrThrowWithMessage(createSqliteDatabase(databaseDeps)({
130
+ type: "File",
131
+ vfs: await openPool(),
132
+ path: databasePath,
133
+ })));
169
134
  break;
170
- }
171
135
  default:
172
136
  exhaustiveCheck(options);
173
137
  }
174
- const cache = disposer.use(createPreparedStatementsCache((sql) => db.prepare(sql), (statement) => {
175
- statement.finalize();
138
+ const cache = disposer.use(createPreparedStatementsCache((sql) => getOrThrowWithMessage(database.prepare(sql)), (statement) => {
139
+ statement[Symbol.dispose]();
176
140
  }));
177
141
  const disposables = disposer.move();
178
142
  return ok({
179
143
  exec: (query) => {
180
- const prepared = cache.get(query);
181
- if (prepared) {
182
- try {
183
- if (query.parameters.length > 0)
184
- prepared.bind(query.parameters);
185
- const rows = [];
186
- while (prepared.step()) {
187
- rows.push(prepared.get({}));
188
- }
189
- return {
190
- rows: rows,
191
- changes: db.changes(),
192
- };
193
- }
194
- finally {
195
- // SQLite refuses to bind a statement whose step failed until it is
196
- // reset. That reset returns the step's error again, which
197
- // PreparedStatement.reset would throw over the original one.
198
- sqlite3.capi.sqlite3_reset(prepared);
199
- }
200
- }
201
- const rows = db.exec(query.sql, {
202
- returnValue: "resultRows",
203
- rowMode: "object",
204
- bind: query.parameters,
205
- });
206
- const changes = db.changes();
207
- return { rows, changes };
144
+ const statement = cache.get(query);
145
+ return getOrThrowWithMessage(statement
146
+ ? statement.run(query.parameters)
147
+ : database.run(query.sql, query.parameters));
208
148
  },
209
- export: () => sqlite3.capi.sqlite3_js_db_export(db),
149
+ export: () => getOrThrowWithMessage(database.export()),
210
150
  deleteDatabase: () => {
211
151
  deleteDatabaseFile = true;
212
152
  disposables.dispose();
@@ -224,3 +164,55 @@ export const createWasmSqliteDriver = (name, options) => async (run) => {
224
164
  __disposeResources(env_1);
225
165
  }
226
166
  };
167
+ /**
168
+ * Creates the {@link WaitForDatabaseRelease} of Evolu for the web. It opens the
169
+ * pool {@link createWasmSqliteDriver} opens for the database, and disposes it
170
+ * once it opens.
171
+ *
172
+ * When another context holds a file of the pool, the pool is opened again after
173
+ * 50 ms, then after twice the previous delay, up to a second, with the
174
+ * `heldTimeout` option of {@link openSahPool}, and the wait fails with
175
+ * {@link DatabaseHeldError} when it is still held 10 seconds after the first
176
+ * attempt started. Evolu then refuses to start the database, and the app
177
+ * receives the error as its `evoluError`. Every other error is thrown, as the
178
+ * driver throws it.
179
+ */
180
+ export const createWaitForDatabaseRelease = (deps) => (name) => async (run) => {
181
+ const env_2 = { stack: [], error: void 0, hasError: false };
182
+ try {
183
+ const sqliteWasm = getOrThrowWithMessage(await deps.sqliteWasmLoad);
184
+ const opened = await run(
185
+ // Longer than the two seconds Chromium gives a terminated worker.
186
+ openDatabasePool(name, { heldTimeout: "10s" }), { ...deps, sqliteWasm });
187
+ if (!opened.ok && opened.error.type === "SahPoolHeldError")
188
+ return err({ type: "DatabaseHeldError", name });
189
+ const _pool = __addDisposableResource(env_2, getOrThrowWithMessage(opened), false);
190
+ return ok();
191
+ }
192
+ catch (e_2) {
193
+ env_2.error = e_2;
194
+ env_2.hasError = true;
195
+ }
196
+ finally {
197
+ __disposeResources(env_2);
198
+ }
199
+ };
200
+ // The pool of a database's file is in the directory `.<name>`, as in
201
+ // @evolu/web 3.
202
+ const openDatabasePool = (name, options) =>
203
+ // A Name is non-empty and URL-safe, so `.<name>` always passes.
204
+ openSahPool({ ...options, directory: [OpfsName.orThrow(`.${name}`)] });
205
+ // The path SQLite's opfs-sahpool gave `file:evolu1.db`, which @evolu/web 3
206
+ // opened.
207
+ const databasePath = /*#__PURE__*/ SqliteVfsPath.orThrow("/evolu1.db");
208
+ // The SqliteDriver contract has no error channel, and the WaitForDatabaseRelease
209
+ // contract only DatabaseHeldError, so any other error is thrown as the cause of
210
+ // an Error, with SQLite's message when it has one.
211
+ const getOrThrowWithMessage = (result) => {
212
+ if (result.ok)
213
+ return result.value;
214
+ const { error } = result;
215
+ throw new Error("message" in error && typeof error.message === "string"
216
+ ? error.message
217
+ : error.type, { cause: error });
218
+ };
@@ -3,13 +3,22 @@ import { installPolyfills } from "@evolu/common/polyfills";
3
3
  installPolyfills();
4
4
  import { createRandomBytes } from "@evolu/common";
5
5
  import { startDbWorker } from "@evolu/common/local-first";
6
- import { createWasmSqliteDriver } from "../Sqlite.js";
6
+ import { createWaitForDatabaseRelease, createWasmSqliteDriver, loadSqliteWasm, } from "../Sqlite.js";
7
7
  import { createRun } from "../Task.js";
8
8
  import { createWorkerDeps, createWorkerSelf } from "../Worker.js";
9
9
  const run = createRun({
10
10
  ...createWorkerDeps(),
11
- createSqliteDriver: createWasmSqliteDriver,
12
11
  lockManager: navigator.locks,
13
12
  randomBytes: createRandomBytes(),
14
13
  });
15
- void run(startDbWorker(createWorkerSelf(self)));
14
+ const sqliteDeps = {
15
+ opfsRoot: navigator.storage,
16
+ // SQLite loads while the DbWorker waits for its database.
17
+ sqliteWasmLoad: run(loadSqliteWasm),
18
+ subtleCrypto: crypto.subtle,
19
+ };
20
+ void run(startDbWorker(createWorkerSelf(self)), {
21
+ ...run.deps,
22
+ createSqliteDriver: createWasmSqliteDriver(sqliteDeps),
23
+ waitForDatabaseRelease: createWaitForDatabaseRelease(sqliteDeps),
24
+ });
@@ -22,8 +22,9 @@ export interface SharedWorkerUnsupportedDep {
22
22
  *
23
23
  * When the page enters the browser's back-forward cache, as Safari does on
24
24
  * every navigation away, this tab ends its part as if it closed: it stops the
25
- * database workers it hosts, so another tab takes them over, and it reloads if
26
- * the user comes back to it.
25
+ * database workers it hosts, so another tab takes them over, the shared worker
26
+ * ends this tab's Evolu instances, and the tab reloads if the user comes back
27
+ * to it.
27
28
  *
28
29
  * Where the browser offers no persistent storage, as in Safari's Private
29
30
  * Browsing or a Firefox private window, the database is kept in memory, and
@@ -1 +1 @@
1
- {"version":3,"file":"Evolu.d.ts","sourceRoot":"","sources":["../../../src/local-first/Evolu.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,UAAU,EAEf,KAAK,YAAY,EAElB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAKV,SAAS,EACT,2BAA2B,EAI5B,MAAM,2BAA2B,CAAC;AAgBnC,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,IAAI,EAAE,yBAAyB,CAAC;CAC1C;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,yBAAyB,EAAE,MAAM,IAAI,CAAC;CAChD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,eAAO,MAAM,eAAe,UACpB,OAAO,CAAC,UAAU,CAAC,GACvB,OAAO,CAAC,YAAY,CAAC,GACrB,OAAO,CAAC,0BAA0B,CAAC,GACnC,OAAO,CAAC,2BAA2B,CAAC,KACrC,SAsPF,CAAC"}
1
+ {"version":3,"file":"Evolu.d.ts","sourceRoot":"","sources":["../../../src/local-first/Evolu.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,UAAU,EAEf,KAAK,YAAY,EAElB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAKV,SAAS,EACT,2BAA2B,EAI5B,MAAM,2BAA2B,CAAC;AAgBnC,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,IAAI,EAAE,yBAAyB,CAAC;CAC1C;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,yBAAyB,EAAE,MAAM,IAAI,CAAC;CAChD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,eAAe,UACpB,OAAO,CAAC,UAAU,CAAC,GACvB,OAAO,CAAC,YAAY,CAAC,GACrB,OAAO,CAAC,0BAA0B,CAAC,GACnC,OAAO,CAAC,2BAA2B,CAAC,KACrC,SAgSF,CAAC"}
@@ -70,8 +70,9 @@ import { createBroadcastChannel, createMessageChannel, createSharedWorker, creat
70
70
  *
71
71
  * When the page enters the browser's back-forward cache, as Safari does on
72
72
  * every navigation away, this tab ends its part as if it closed: it stops the
73
- * database workers it hosts, so another tab takes them over, and it reloads if
74
- * the user comes back to it.
73
+ * database workers it hosts, so another tab takes them over, the shared worker
74
+ * ends this tab's Evolu instances, and the tab reloads if the user comes back
75
+ * to it.
75
76
  *
76
77
  * Where the browser offers no persistent storage, as in Safari's Private
77
78
  * Browsing or a Firefox private window, the database is kept in memory, and
@@ -276,21 +277,45 @@ export const createEvoluDeps = (deps = {}) => {
276
277
  await navigator.storage.persist();
277
278
  });
278
279
  };
280
+ // Disposing the deps releases the locks this page holds through them, as
281
+ // closing the page would, and a lock granted afterwards is released at once.
282
+ // The SharedWorker learns that an Evolu instance ended when it gets the lock
283
+ // the instance holds. Chrome keeps the locks of a page in its back-forward
284
+ // cache, and a request made before the page entered the cache waits until
285
+ // Chrome drops the page (https://issues.chromium.org/issues/567630881), so
286
+ // the SharedWorker would keep syncing the owners of a cached tab's instances
287
+ // and rerunning their queries.
288
+ let areLocksReleased = false;
289
+ const locksReleased = Promise.withResolvers();
290
+ disposer.defer(() => {
291
+ areLocksReleased = true;
292
+ locksReleased.resolve();
293
+ });
294
+ function requestLock(name, ...args) {
295
+ const [options, callback] = args.length === 1 ? [{}, args[0]] : args;
296
+ return navigator.locks.request(name, options, (lock) => areLocksReleased
297
+ ? undefined
298
+ : Promise.race([callback(lock), locksReleased.promise]));
299
+ }
279
300
  const evoluDeps = disposer.use(createCommonEvoluDeps({
280
301
  requestPersistentStorage,
281
302
  ...deps,
282
303
  createDbWorker,
283
304
  createBroadcastChannel,
284
305
  createMessageChannel,
285
- lockManager: navigator.locks,
306
+ lockManager: {
307
+ query: () => navigator.locks.query(),
308
+ request: requestLock,
309
+ },
286
310
  reloadApp: reloadThisApp,
287
311
  sharedWorker,
288
312
  }));
289
- // A page entering the back-forward cache is frozen with its DbWorkers, which
290
- // keep their database locks, so the other tabs would stall. WebKit also
291
- // releases the page's own locks, so a restored page would work with state
292
- // the other tabs gave up on. The page therefore ends its part as if it
293
- // closed, and reloads when it is shown again.
313
+ // A page entering the back-forward cache is frozen with its DbWorkers, and
314
+ // WebKit keeps their database locks while the page is cached
315
+ // (https://bugs.webkit.org/show_bug.cgi?id=316904), so the other tabs would
316
+ // stall. WebKit also releases the page's own locks, so a restored page would
317
+ // work with state the other tabs gave up on. The page therefore ends its part
318
+ // as if it closed, and reloads when it is shown again.
294
319
  const reloadRestoredPage = (event) => {
295
320
  if (event.persisted)
296
321
  reloadThisApp();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evolu/web",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Evolu for web",
5
5
  "keywords": [
6
6
  "evolu",
@@ -32,10 +32,10 @@
32
32
  "README.md"
33
33
  ],
34
34
  "dependencies": {
35
- "@evolu/sqlite-wasm": "2.2.4"
35
+ "@evolu/sqlite-wasm": "3.53.4-build1"
36
36
  },
37
37
  "devDependencies": {
38
- "@evolu/common": "8.14.0",
38
+ "@evolu/common": "8.19.0",
39
39
  "@evolu/typescript-config": "0.1.1",
40
40
  "@types/node": "^24.10.9",
41
41
  "@types/sharedworker": "^0.0.229",
@@ -44,7 +44,7 @@
44
44
  "user-agent-data-types": "^0.4.2"
45
45
  },
46
46
  "peerDependencies": {
47
- "@evolu/common": "^8.14.0"
47
+ "@evolu/common": "^8.19.0"
48
48
  },
49
49
  "publishConfig": {
50
50
  "access": "public"