@evolu/web 3.2.0 → 3.4.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/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +17 -10
- package/dist/src/Worker.d.ts +38 -1
- package/dist/src/Worker.d.ts.map +1 -1
- package/dist/src/Worker.js +61 -2
- package/dist/src/local-first/Evolu.d.ts +16 -21
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +27 -9
- package/dist/src/local-first/Shared.worker.js +18 -6
- package/package.json +3 -3
- package/src/Sqlite.test.ts +5 -4
- package/src/Sqlite.ts +17 -11
- package/src/Worker.test.ts +59 -0
- package/src/Worker.ts +66 -1
- package/src/local-first/Evolu.test.ts +58 -16
- package/src/local-first/Evolu.ts +29 -27
- package/src/local-first/Shared.worker.ts +36 -8
package/dist/src/Sqlite.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/src/Sqlite.js
CHANGED
|
@@ -179,17 +179,24 @@ export const createWasmSqliteDriver = (name, options) => async (run) => {
|
|
|
179
179
|
exec: (query) => {
|
|
180
180
|
const prepared = cache.get(query);
|
|
181
181
|
if (prepared) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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);
|
|
187
199
|
}
|
|
188
|
-
prepared.reset();
|
|
189
|
-
return {
|
|
190
|
-
rows: rows,
|
|
191
|
-
changes: db.changes(),
|
|
192
|
-
};
|
|
193
200
|
}
|
|
194
201
|
const rows = db.exec(query.sql, {
|
|
195
202
|
returnValue: "resultRows",
|
package/dist/src/Worker.d.ts
CHANGED
|
@@ -44,7 +44,9 @@ export declare const createWorkerSelf: <Input, Output = never>(nativeSelf: Dedic
|
|
|
44
44
|
* Creates an Evolu {@link SharedWorkerSelf} from a Web `SharedWorkerGlobalScope`
|
|
45
45
|
* (`self` inside a shared worker).
|
|
46
46
|
*
|
|
47
|
-
* Disposing closes the shared worker scope for all connected clients.
|
|
47
|
+
* Disposing closes the shared worker scope for all connected clients. Browsers
|
|
48
|
+
* never pass a shared worker's uncaught errors to its pages; see
|
|
49
|
+
* {@link addUncaughtErrorListener}.
|
|
48
50
|
*/
|
|
49
51
|
export declare const createSharedWorkerSelf: <Input, Output = never>(nativeSelf: SharedWorkerGlobalScope) => SharedWorkerSelf<Input, Output>;
|
|
50
52
|
/**
|
|
@@ -55,6 +57,41 @@ export declare const createSharedWorkerSelf: <Input, Output = never>(nativeSelf:
|
|
|
55
57
|
* `onMessage` is set, and does not share state across tabs.
|
|
56
58
|
*/
|
|
57
59
|
export declare const createOneTabSharedWorkerSelfPolyfill: <Input, Output = never>(nativeSelf: DedicatedWorkerGlobalScope) => SharedWorkerSelf<Input, Output>;
|
|
60
|
+
/**
|
|
61
|
+
* Passes each uncaught error and unhandled rejection of a worker global scope
|
|
62
|
+
* to `listener` as an `Error` from {@link defectToError}.
|
|
63
|
+
*
|
|
64
|
+
* Browsers never pass a shared worker's uncaught errors to a page's error
|
|
65
|
+
* handlers, and no worker passes its unhandled rejections to its page. A shared
|
|
66
|
+
* worker calls this with its global scope, `self`, to send them to its pages,
|
|
67
|
+
* for example over a BroadcastChannel. Defects of a Run from {@link createRun}
|
|
68
|
+
* arrive too, because it reports them with `reportError`, which dispatches
|
|
69
|
+
* "error" on the global scope.
|
|
70
|
+
*
|
|
71
|
+
* The events stay uncanceled, so the browser still logs them, and a dedicated
|
|
72
|
+
* worker still passes its uncaught errors to its page. The listeners stay until
|
|
73
|
+
* disposal, so they also receive errors after a Run's panic.
|
|
74
|
+
*
|
|
75
|
+
* ### Example
|
|
76
|
+
*
|
|
77
|
+
* ```ts
|
|
78
|
+
* import { assertEqual } from "@evolu/common";
|
|
79
|
+
* import { addUncaughtErrorListener } from "@evolu/web";
|
|
80
|
+
*
|
|
81
|
+
* const messages: Array<string> = [];
|
|
82
|
+
* // A worker passes its global scope, `self`.
|
|
83
|
+
* const scope = new EventTarget();
|
|
84
|
+
* using _listener = addUncaughtErrorListener(scope, (error) => {
|
|
85
|
+
* messages.push(error.message);
|
|
86
|
+
* });
|
|
87
|
+
*
|
|
88
|
+
* scope.dispatchEvent(
|
|
89
|
+
* Object.assign(new Event("error"), { error: new Error("unexpected") }),
|
|
90
|
+
* );
|
|
91
|
+
* assertEqual(messages, ["unexpected"]);
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export declare const addUncaughtErrorListener: (scope: EventTarget, listener: (error: Error) => void) => Disposable;
|
|
58
95
|
/** Creates deps shared by web worker entry points. */
|
|
59
96
|
export declare const createWorkerDeps: () => WorkerDeps & CreateBroadcastChannelDep & CreateMessageChannelDep;
|
|
60
97
|
//# sourceMappingURL=Worker.d.ts.map
|
package/dist/src/Worker.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Worker.d.ts","sourceRoot":"","sources":["../../src/Worker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,sBAAsB,EACtB,yBAAyB,EACzB,uBAAuB,EACvB,iBAAiB,EACjB,cAAc,EAGd,YAAY,EACZ,gBAAgB,EAEhB,MAAM,EACN,UAAU,EACV,UAAU,EACX,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"Worker.d.ts","sourceRoot":"","sources":["../../src/Worker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,sBAAsB,EACtB,yBAAyB,EACzB,uBAAuB,EACvB,iBAAiB,EACjB,cAAc,EAGd,YAAY,EACZ,gBAAgB,EAEhB,MAAM,EACN,UAAU,EACV,UAAU,EACX,MAAM,eAAe,CAAC;AAUvB,kDAAkD;AAClD,eAAO,MAAM,YAAY,GAAI,KAAK,EAAE,MAAM,gBAC1B,UAAU,CAAC,MAAM,KAC9B,MAAM,CAAC,KAAK,EAAE,MAAM,CAAuB,CAAC;AAE/C;;;;;GAKG;AACH,eAAO,MAAM,iCAAiC,QAAO,IAiBpD,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAI,KAAK,EAAE,MAAM,GAAG,KAAK,sBAClC,UAAU,CAAC,YAAY,KAC1C,YAAY,CAAC,KAAK,EAAE,MAAM,CAM5B,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,EAAE,MAAM,GAAG,KAAK,OAAK,cAAc,CAC3E,KAAK,EACL,MAAM,CAcP,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,EAAE,iBACuB,CAAC;AAExD,6EAA6E;AAC7E,eAAO,MAAM,sBAAsB,EAAE,sBAwCpC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAAI,KAAK,EAAE,MAAM,GAAG,KAAK,cACxC,0BAA0B,KACrC,UAAU,CAAC,KAAK,EAAE,MAAM,CAAoC,CAAC;AAEhE;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,GAAI,KAAK,EAAE,MAAM,GAAG,KAAK,cAC9C,uBAAuB,KAClC,gBAAgB,CAAC,KAAK,EAAE,MAAM,CAoBhC,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,oCAAoC,GAAI,KAAK,EAAE,MAAM,GAAG,KAAK,cAC5D,0BAA0B,KACrC,gBAAgB,CAAC,KAAK,EAAE,MAAM,CA2EhC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,eAAO,MAAM,wBAAwB,UAC5B,WAAW,YACR,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,KAC/B,UAsBF,CAAC;AAEF,sDAAsD;AACtD,eAAO,MAAM,gBAAgB,QAAO,UAAU,GAC5C,yBAAyB,GACzB,uBAWD,CAAC"}
|
package/dist/src/Worker.js
CHANGED
|
@@ -50,7 +50,7 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
|
|
|
50
50
|
var e = new Error(message);
|
|
51
51
|
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
52
|
});
|
|
53
|
-
import { assertNonNullable, createConsole, createConsoleStoreOutput, disposable, } from "@evolu/common";
|
|
53
|
+
import { assertNonNullable, createConsole, createConsoleStoreOutput, defectToError, disposable, } from "@evolu/common";
|
|
54
54
|
/** Creates a {@link Worker} from a Web Worker. */
|
|
55
55
|
export const createWorker = (nativeWorker) => wrap(nativeWorker);
|
|
56
56
|
/**
|
|
@@ -176,7 +176,9 @@ export const createWorkerSelf = (nativeSelf) => wrap(nativeSelf);
|
|
|
176
176
|
* Creates an Evolu {@link SharedWorkerSelf} from a Web `SharedWorkerGlobalScope`
|
|
177
177
|
* (`self` inside a shared worker).
|
|
178
178
|
*
|
|
179
|
-
* Disposing closes the shared worker scope for all connected clients.
|
|
179
|
+
* Disposing closes the shared worker scope for all connected clients. Browsers
|
|
180
|
+
* never pass a shared worker's uncaught errors to its pages; see
|
|
181
|
+
* {@link addUncaughtErrorListener}.
|
|
180
182
|
*/
|
|
181
183
|
export const createSharedWorkerSelf = (nativeSelf) => {
|
|
182
184
|
const self = {
|
|
@@ -285,6 +287,63 @@ export const createOneTabSharedWorkerSelfPolyfill = (nativeSelf) => {
|
|
|
285
287
|
__disposeResources(env_3);
|
|
286
288
|
}
|
|
287
289
|
};
|
|
290
|
+
/**
|
|
291
|
+
* Passes each uncaught error and unhandled rejection of a worker global scope
|
|
292
|
+
* to `listener` as an `Error` from {@link defectToError}.
|
|
293
|
+
*
|
|
294
|
+
* Browsers never pass a shared worker's uncaught errors to a page's error
|
|
295
|
+
* handlers, and no worker passes its unhandled rejections to its page. A shared
|
|
296
|
+
* worker calls this with its global scope, `self`, to send them to its pages,
|
|
297
|
+
* for example over a BroadcastChannel. Defects of a Run from {@link createRun}
|
|
298
|
+
* arrive too, because it reports them with `reportError`, which dispatches
|
|
299
|
+
* "error" on the global scope.
|
|
300
|
+
*
|
|
301
|
+
* The events stay uncanceled, so the browser still logs them, and a dedicated
|
|
302
|
+
* worker still passes its uncaught errors to its page. The listeners stay until
|
|
303
|
+
* disposal, so they also receive errors after a Run's panic.
|
|
304
|
+
*
|
|
305
|
+
* ### Example
|
|
306
|
+
*
|
|
307
|
+
* ```ts
|
|
308
|
+
* import { assertEqual } from "@evolu/common";
|
|
309
|
+
* import { addUncaughtErrorListener } from "@evolu/web";
|
|
310
|
+
*
|
|
311
|
+
* const messages: Array<string> = [];
|
|
312
|
+
* // A worker passes its global scope, `self`.
|
|
313
|
+
* const scope = new EventTarget();
|
|
314
|
+
* using _listener = addUncaughtErrorListener(scope, (error) => {
|
|
315
|
+
* messages.push(error.message);
|
|
316
|
+
* });
|
|
317
|
+
*
|
|
318
|
+
* scope.dispatchEvent(
|
|
319
|
+
* Object.assign(new Event("error"), { error: new Error("unexpected") }),
|
|
320
|
+
* );
|
|
321
|
+
* assertEqual(messages, ["unexpected"]);
|
|
322
|
+
* ```
|
|
323
|
+
*/
|
|
324
|
+
export const addUncaughtErrorListener = (scope, listener) => {
|
|
325
|
+
const env_4 = { stack: [], error: void 0, hasError: false };
|
|
326
|
+
try {
|
|
327
|
+
const disposer = __addDisposableResource(env_4, new DisposableStack(), false);
|
|
328
|
+
const { signal } = disposer.adopt(new AbortController(), (controller) => {
|
|
329
|
+
controller.abort();
|
|
330
|
+
});
|
|
331
|
+
scope.addEventListener("error", (event) => {
|
|
332
|
+
listener(defectToError(event.error));
|
|
333
|
+
}, { signal });
|
|
334
|
+
scope.addEventListener("unhandledrejection", (event) => {
|
|
335
|
+
listener(defectToError(event.reason));
|
|
336
|
+
}, { signal });
|
|
337
|
+
return disposable({}, disposer);
|
|
338
|
+
}
|
|
339
|
+
catch (e_4) {
|
|
340
|
+
env_4.error = e_4;
|
|
341
|
+
env_4.hasError = true;
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
__disposeResources(env_4);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
288
347
|
/** Creates deps shared by web worker entry points. */
|
|
289
348
|
export const createWorkerDeps = () => {
|
|
290
349
|
const consoleStoreOutput = createConsoleStoreOutput();
|
|
@@ -1,27 +1,11 @@
|
|
|
1
1
|
import { type ConsoleDep, type ReloadAppDep } from "@evolu/common";
|
|
2
|
-
import type { EvoluDeps } from "@evolu/common/local-first";
|
|
2
|
+
import type { EvoluDeps, RequestPersistentStorageDep } from "@evolu/common/local-first";
|
|
3
3
|
export interface SharedWorkerUnsupported {
|
|
4
4
|
readonly type: "SharedWorkerUnsupported";
|
|
5
5
|
}
|
|
6
6
|
export interface SharedWorkerUnsupportedDep {
|
|
7
7
|
readonly onSharedWorkerUnsupported: () => void;
|
|
8
8
|
}
|
|
9
|
-
export interface StorageUnavailableDep {
|
|
10
|
-
/**
|
|
11
|
-
* Called when the browser offers no persistent storage, as in Safari's
|
|
12
|
-
* Private Browsing, so every database is kept in memory. See Storage in the
|
|
13
|
-
* Shared module of `@evolu/common`.
|
|
14
|
-
*
|
|
15
|
-
* The app keeps working. Data synced with a relay comes back, as on a new
|
|
16
|
-
* device, and nothing stays on the device once the tabs close. That suits
|
|
17
|
-
* someone checking their app on a borrowed phone, so a message such as
|
|
18
|
-
* "Nothing from this session is kept on this device." tells the user what to
|
|
19
|
-
* expect. Data that exists only locally, or has not synced yet, is lost when
|
|
20
|
-
* the tab hosting the database closes or navigates away, even while other
|
|
21
|
-
* tabs stay open.
|
|
22
|
-
*/
|
|
23
|
-
readonly onStorageUnavailable: () => void;
|
|
24
|
-
}
|
|
25
9
|
/**
|
|
26
10
|
* Creates Evolu dependencies for the web platform.
|
|
27
11
|
*
|
|
@@ -42,14 +26,25 @@ export interface StorageUnavailableDep {
|
|
|
42
26
|
* the user comes back to it.
|
|
43
27
|
*
|
|
44
28
|
* Where the browser offers no persistent storage, as in Safari's Private
|
|
45
|
-
* Browsing, the database is kept in memory, and
|
|
46
|
-
* {@link
|
|
47
|
-
* user.
|
|
29
|
+
* Browsing or a Firefox private window, the database is kept in memory, and
|
|
30
|
+
* {@link Evolu.devicePersistence} resolves to `NotPersisted`, so the app can
|
|
31
|
+
* tell the user. Data that exists only locally, or has not synced yet, is lost
|
|
32
|
+
* when the tab hosting the database closes or navigates away, even while other
|
|
33
|
+
* tabs stay open.
|
|
34
|
+
*
|
|
35
|
+
* After the first local mutation of a database the browser stores, this tab
|
|
36
|
+
* asks the browser once with `navigator.storage.persist()` not to delete the
|
|
37
|
+
* site's data when disk space runs low. Chrome and Safari decide silently, and
|
|
38
|
+
* Firefox asks the user, so every tab asks until the user allows it. A custom
|
|
39
|
+
* {@link RequestPersistentStorageDep.requestPersistentStorage} replaces the
|
|
40
|
+
* request, for example with `constVoid` to never ask. See [Will my data stay on
|
|
41
|
+
* the
|
|
42
|
+
* device?](https://www.evolu.dev/docs/faq#will-my-data-stay-on-the-device).
|
|
48
43
|
*
|
|
49
44
|
* A custom {@link ReloadApp} replaces the default page reload, for example to
|
|
50
45
|
* save state first. It should end by reloading the page, because the other
|
|
51
46
|
* build waits until this tab reloads or closes, and a page restored from the
|
|
52
47
|
* back-forward cache cannot work until it reloads.
|
|
53
48
|
*/
|
|
54
|
-
export declare const createEvoluDeps: (deps?: Partial<ConsoleDep> & Partial<ReloadAppDep> & Partial<SharedWorkerUnsupportedDep> & Partial<
|
|
49
|
+
export declare const createEvoluDeps: (deps?: Partial<ConsoleDep> & Partial<ReloadAppDep> & Partial<SharedWorkerUnsupportedDep> & Partial<RequestPersistentStorageDep>) => EvoluDeps;
|
|
55
50
|
//# sourceMappingURL=Evolu.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Evolu.d.ts","sourceRoot":"","sources":["../../../src/local-first/Evolu.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
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"}
|
|
@@ -50,7 +50,7 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
|
|
|
50
50
|
var e = new Error(message);
|
|
51
51
|
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
52
|
});
|
|
53
|
-
import { exhaustiveCheck, trySync, } from "@evolu/common";
|
|
53
|
+
import { exhaustiveCheck, tryAsync, trySync, } from "@evolu/common";
|
|
54
54
|
import { BuildWaiting, BuildWaitingRequest, buildsBroadcastChannelName, createEvoluDeps as createCommonEvoluDeps, } from "@evolu/common/local-first";
|
|
55
55
|
import { reloadApp } from "../Platform.js";
|
|
56
56
|
import { createBroadcastChannel, createMessageChannel, createSharedWorker, createWorker, installOneTabSharedWorkerPolyfill, } from "../Worker.js";
|
|
@@ -74,9 +74,20 @@ import { createBroadcastChannel, createMessageChannel, createSharedWorker, creat
|
|
|
74
74
|
* the user comes back to it.
|
|
75
75
|
*
|
|
76
76
|
* Where the browser offers no persistent storage, as in Safari's Private
|
|
77
|
-
* Browsing, the database is kept in memory, and
|
|
78
|
-
* {@link
|
|
79
|
-
* user.
|
|
77
|
+
* Browsing or a Firefox private window, the database is kept in memory, and
|
|
78
|
+
* {@link Evolu.devicePersistence} resolves to `NotPersisted`, so the app can
|
|
79
|
+
* tell the user. Data that exists only locally, or has not synced yet, is lost
|
|
80
|
+
* when the tab hosting the database closes or navigates away, even while other
|
|
81
|
+
* tabs stay open.
|
|
82
|
+
*
|
|
83
|
+
* After the first local mutation of a database the browser stores, this tab
|
|
84
|
+
* asks the browser once with `navigator.storage.persist()` not to delete the
|
|
85
|
+
* site's data when disk space runs low. Chrome and Safari decide silently, and
|
|
86
|
+
* Firefox asks the user, so every tab asks until the user allows it. A custom
|
|
87
|
+
* {@link RequestPersistentStorageDep.requestPersistentStorage} replaces the
|
|
88
|
+
* request, for example with `constVoid` to never ask. See [Will my data stay on
|
|
89
|
+
* the
|
|
90
|
+
* device?](https://www.evolu.dev/docs/faq#will-my-data-stay-on-the-device).
|
|
80
91
|
*
|
|
81
92
|
* A custom {@link ReloadApp} replaces the default page reload, for example to
|
|
82
93
|
* save state first. It should end by reloading the page, because the other
|
|
@@ -105,6 +116,7 @@ export const createEvoluDeps = (deps = {}) => {
|
|
|
105
116
|
const earlyAnnouncedWorkerIds = new Set();
|
|
106
117
|
// Checks whether the user left this tab while another build waits.
|
|
107
118
|
let focusCheckId = null;
|
|
119
|
+
let isPersistentStorageRequested = false;
|
|
108
120
|
const disposer = __addDisposableResource(env_1, new DisposableStack(), false);
|
|
109
121
|
const buildsBroadcastChannel = disposer.use(createBroadcastChannel(buildsBroadcastChannelName));
|
|
110
122
|
const stopFocusCheck = () => {
|
|
@@ -206,11 +218,6 @@ export const createEvoluDeps = (deps = {}) => {
|
|
|
206
218
|
});
|
|
207
219
|
break;
|
|
208
220
|
}
|
|
209
|
-
case "StorageUnavailable": {
|
|
210
|
-
deps.onStorageUnavailable?.();
|
|
211
|
-
forward(message);
|
|
212
|
-
break;
|
|
213
|
-
}
|
|
214
221
|
case "SharedWorkerUnsupported": {
|
|
215
222
|
if (deps.onSharedWorkerUnsupported) {
|
|
216
223
|
deps.onSharedWorkerUnsupported();
|
|
@@ -259,7 +266,18 @@ export const createEvoluDeps = (deps = {}) => {
|
|
|
259
266
|
},
|
|
260
267
|
},
|
|
261
268
|
};
|
|
269
|
+
const requestPersistentStorage = () => {
|
|
270
|
+
if (isPersistentStorageRequested)
|
|
271
|
+
return;
|
|
272
|
+
isPersistentStorageRequested = true;
|
|
273
|
+
void tryAsync(async () => {
|
|
274
|
+
if (await navigator.storage.persisted())
|
|
275
|
+
return;
|
|
276
|
+
await navigator.storage.persist();
|
|
277
|
+
});
|
|
278
|
+
};
|
|
262
279
|
const evoluDeps = disposer.use(createCommonEvoluDeps({
|
|
280
|
+
requestPersistentStorage,
|
|
263
281
|
...deps,
|
|
264
282
|
createDbWorker,
|
|
265
283
|
createBroadcastChannel,
|
|
@@ -53,15 +53,25 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
|
|
|
53
53
|
/// <reference lib="webworker" />
|
|
54
54
|
import { installPolyfills } from "@evolu/common/polyfills";
|
|
55
55
|
installPolyfills();
|
|
56
|
-
import { createWebSocket, ok, tryAsync, waitForAbort } from "@evolu/common";
|
|
57
|
-
import { initSharedWorker } from "@evolu/common/local-first";
|
|
56
|
+
import { createUnknownError, createWebSocket, ok, tryAsync, waitForAbort, } from "@evolu/common";
|
|
57
|
+
import { consoleEntryOrErrorBroadcastChannelName, initSharedWorker, } from "@evolu/common/local-first";
|
|
58
58
|
import { createRun } from "../Task.js";
|
|
59
|
-
import { createOneTabSharedWorkerSelfPolyfill, createSharedWorkerSelf, createWorkerDeps, } from "../Worker.js";
|
|
59
|
+
import { addUncaughtErrorListener, createBroadcastChannel, createOneTabSharedWorkerSelfPolyfill, createSharedWorkerSelf, createWorkerDeps, } from "../Worker.js";
|
|
60
|
+
const errors = createBroadcastChannel(consoleEntryOrErrorBroadcastChannelName);
|
|
61
|
+
// Each tab logs an uncaught error, including a Run defect, and sets it as its
|
|
62
|
+
// evoluError.
|
|
63
|
+
addUncaughtErrorListener(self, (error) => {
|
|
64
|
+
errors.postMessage({ type: "Error", error: createUnknownError(error) });
|
|
65
|
+
});
|
|
60
66
|
const run = createRun({
|
|
61
67
|
...createWorkerDeps(),
|
|
62
68
|
createWebSocket,
|
|
63
|
-
// Safari's Private Browsing
|
|
64
|
-
|
|
69
|
+
// Safari's Private Browsing and Firefox's private windows offer no OPFS, and
|
|
70
|
+
// a browser may delete it, as Chrome's incognito does when the session ends;
|
|
71
|
+
// see Storage in the Shared module.
|
|
72
|
+
getDevicePersistence: async () => (await tryAsync(() => navigator.storage.getDirectory())).ok
|
|
73
|
+
? "Unknown"
|
|
74
|
+
: "NotPersisted",
|
|
65
75
|
lockManager: navigator.locks,
|
|
66
76
|
});
|
|
67
77
|
void run(async (run) => {
|
|
@@ -70,7 +80,9 @@ void run(async (run) => {
|
|
|
70
80
|
if ("onconnect" in self) {
|
|
71
81
|
const env_2 = { stack: [], error: void 0, hasError: false };
|
|
72
82
|
try {
|
|
73
|
-
|
|
83
|
+
// A panic closes the worker, so tabs that connect later start another.
|
|
84
|
+
const workerSelf = __addDisposableResource(env_2, createSharedWorkerSelf(self), false);
|
|
85
|
+
const _ = __addDisposableResource(env_2, await run.ok(initSharedWorker(workerSelf)), true);
|
|
74
86
|
return await run(waitForAbort);
|
|
75
87
|
}
|
|
76
88
|
catch (e_1) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evolu/web",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "Evolu for web",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"evolu",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"@evolu/sqlite-wasm": "2.2.4"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@evolu/common": "8.
|
|
38
|
+
"@evolu/common": "8.14.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.
|
|
47
|
+
"@evolu/common": "^8.14.0"
|
|
48
48
|
},
|
|
49
49
|
"publishConfig": {
|
|
50
50
|
"access": "public"
|
package/src/Sqlite.test.ts
CHANGED
|
@@ -117,10 +117,6 @@ const sqliteMock = (() => {
|
|
|
117
117
|
return { data: "prepared" };
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
reset(): void {
|
|
121
|
-
this.resetCount += 1;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
120
|
finalize(): void {
|
|
125
121
|
this.finalized = true;
|
|
126
122
|
}
|
|
@@ -193,6 +189,10 @@ const sqliteMock = (() => {
|
|
|
193
189
|
const sqlite3 = {
|
|
194
190
|
capi: {
|
|
195
191
|
sqlite3_js_db_export: mock.fn(() => new Uint8Array([1, 2, 3])),
|
|
192
|
+
sqlite3_reset: mock.fn((statement: PreparedStatement) => {
|
|
193
|
+
statement.resetCount += 1;
|
|
194
|
+
return 0;
|
|
195
|
+
}),
|
|
196
196
|
sqlite3mc_vfs_create: mock.fn(),
|
|
197
197
|
},
|
|
198
198
|
installOpfsSAHPoolVfs: mock.fn(() => Promise.resolve(pool)),
|
|
@@ -217,6 +217,7 @@ const sqliteMock = (() => {
|
|
|
217
217
|
pool.unpauseVfs.mock.resetCalls();
|
|
218
218
|
pool.unlink.mock.resetCalls();
|
|
219
219
|
sqlite3.capi.sqlite3_js_db_export.mock.resetCalls();
|
|
220
|
+
sqlite3.capi.sqlite3_reset.mock.resetCalls();
|
|
220
221
|
sqlite3.capi.sqlite3mc_vfs_create.mock.resetCalls();
|
|
221
222
|
sqlite3.installOpfsSAHPoolVfs.mock.resetCalls();
|
|
222
223
|
},
|
package/src/Sqlite.ts
CHANGED
|
@@ -178,18 +178,24 @@ export const createWasmSqliteDriver: CreateSqliteDriver =
|
|
|
178
178
|
const prepared = cache.get(query);
|
|
179
179
|
|
|
180
180
|
if (prepared) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
181
|
+
try {
|
|
182
|
+
if (query.parameters.length > 0) prepared.bind(query.parameters);
|
|
183
|
+
|
|
184
|
+
const rows = [];
|
|
185
|
+
while (prepared.step()) {
|
|
186
|
+
rows.push(prepared.get({}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
rows: rows as ReadonlyArray<SqliteRow>,
|
|
191
|
+
changes: db.changes(),
|
|
192
|
+
};
|
|
193
|
+
} finally {
|
|
194
|
+
// SQLite refuses to bind a statement whose step failed until it is
|
|
195
|
+
// reset. That reset returns the step's error again, which
|
|
196
|
+
// PreparedStatement.reset would throw over the original one.
|
|
197
|
+
sqlite3.capi.sqlite3_reset(prepared);
|
|
186
198
|
}
|
|
187
|
-
prepared.reset();
|
|
188
|
-
|
|
189
|
-
return {
|
|
190
|
-
rows: rows as ReadonlyArray<SqliteRow>,
|
|
191
|
-
changes: db.changes(),
|
|
192
|
-
};
|
|
193
199
|
}
|
|
194
200
|
|
|
195
201
|
const rows = db.exec(query.sql, {
|
package/src/Worker.test.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
assertEqual,
|
|
3
|
+
assertFalse,
|
|
4
|
+
assertLength,
|
|
3
5
|
assertNonNullable,
|
|
4
6
|
assertSame,
|
|
5
7
|
assertThrowsInstanceOf,
|
|
@@ -9,6 +11,7 @@ import {
|
|
|
9
11
|
} from "@evolu/common";
|
|
10
12
|
import { describe, it, mock, test } from "node:test";
|
|
11
13
|
import {
|
|
14
|
+
addUncaughtErrorListener,
|
|
12
15
|
createBroadcastChannel,
|
|
13
16
|
createOneTabSharedWorkerSelfPolyfill,
|
|
14
17
|
createMessageChannel,
|
|
@@ -450,6 +453,62 @@ test("createSharedWorkerSelf asserts when a connection arrives before onConnect
|
|
|
450
453
|
);
|
|
451
454
|
});
|
|
452
455
|
|
|
456
|
+
describe("addUncaughtErrorListener", () => {
|
|
457
|
+
const dispatch = (
|
|
458
|
+
scope: EventTarget,
|
|
459
|
+
type: "error" | "unhandledrejection",
|
|
460
|
+
fields: object,
|
|
461
|
+
): Event => {
|
|
462
|
+
const event = Object.assign(new Event(type, { cancelable: true }), fields);
|
|
463
|
+
scope.dispatchEvent(event);
|
|
464
|
+
return event;
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
it("passes uncaught errors and unhandled rejections without canceling them", () => {
|
|
468
|
+
const scope = new EventTarget();
|
|
469
|
+
const listener = mock.fn<(error: Error) => void>();
|
|
470
|
+
using _listener = addUncaughtErrorListener(scope, listener);
|
|
471
|
+
const error = new Error("uncaught");
|
|
472
|
+
const reason = new Error("rejected");
|
|
473
|
+
|
|
474
|
+
const errorEvent = dispatch(scope, "error", { error });
|
|
475
|
+
const rejectionEvent = dispatch(scope, "unhandledrejection", { reason });
|
|
476
|
+
|
|
477
|
+
const errors = listener.mock.calls.map((call) => call.arguments[0]);
|
|
478
|
+
assertLength(errors, 2);
|
|
479
|
+
assertSame(errors[0], error);
|
|
480
|
+
assertSame(errors[1], reason);
|
|
481
|
+
assertFalse(errorEvent.defaultPrevented);
|
|
482
|
+
assertFalse(rejectionEvent.defaultPrevented);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("passes a thrown non-Error as an Error", () => {
|
|
486
|
+
const scope = new EventTarget();
|
|
487
|
+
const listener = mock.fn<(error: Error) => void>();
|
|
488
|
+
using _listener = addUncaughtErrorListener(scope, listener);
|
|
489
|
+
|
|
490
|
+
dispatch(scope, "error", { error: null });
|
|
491
|
+
dispatch(scope, "unhandledrejection", { reason: "rejected" });
|
|
492
|
+
|
|
493
|
+
assertEqual(
|
|
494
|
+
listener.mock.calls.map((call) => call.arguments[0].message),
|
|
495
|
+
["Defect: null", 'Defect: "rejected"'],
|
|
496
|
+
);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("stops passing errors after disposal", () => {
|
|
500
|
+
const scope = new EventTarget();
|
|
501
|
+
const listener = mock.fn<(error: Error) => void>();
|
|
502
|
+
const uncaughtErrorListener = addUncaughtErrorListener(scope, listener);
|
|
503
|
+
|
|
504
|
+
uncaughtErrorListener[Symbol.dispose]();
|
|
505
|
+
dispatch(scope, "error", { error: new Error("uncaught") });
|
|
506
|
+
dispatch(scope, "unhandledrejection", { reason: new Error("rejected") });
|
|
507
|
+
|
|
508
|
+
assertSame(listener.mock.callCount(), 0);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
453
512
|
test("createWorkerDeps stores console output entries and exposes createMessagePort", () => {
|
|
454
513
|
const deps = createWorkerDeps();
|
|
455
514
|
|
package/src/Worker.ts
CHANGED
|
@@ -18,8 +18,10 @@ import {
|
|
|
18
18
|
assertNonNullable,
|
|
19
19
|
createConsole,
|
|
20
20
|
createConsoleStoreOutput,
|
|
21
|
+
defectToError,
|
|
21
22
|
disposable,
|
|
22
23
|
} from "@evolu/common";
|
|
24
|
+
import type { createRun } from "./Task.ts";
|
|
23
25
|
|
|
24
26
|
/** Creates a {@link Worker} from a Web Worker. */
|
|
25
27
|
export const createWorker = <Input, Output>(
|
|
@@ -159,7 +161,9 @@ export const createWorkerSelf = <Input, Output = never>(
|
|
|
159
161
|
* Creates an Evolu {@link SharedWorkerSelf} from a Web `SharedWorkerGlobalScope`
|
|
160
162
|
* (`self` inside a shared worker).
|
|
161
163
|
*
|
|
162
|
-
* Disposing closes the shared worker scope for all connected clients.
|
|
164
|
+
* Disposing closes the shared worker scope for all connected clients. Browsers
|
|
165
|
+
* never pass a shared worker's uncaught errors to its pages; see
|
|
166
|
+
* {@link addUncaughtErrorListener}.
|
|
163
167
|
*/
|
|
164
168
|
export const createSharedWorkerSelf = <Input, Output = never>(
|
|
165
169
|
nativeSelf: SharedWorkerGlobalScope,
|
|
@@ -271,6 +275,67 @@ export const createOneTabSharedWorkerSelfPolyfill = <Input, Output = never>(
|
|
|
271
275
|
};
|
|
272
276
|
};
|
|
273
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Passes each uncaught error and unhandled rejection of a worker global scope
|
|
280
|
+
* to `listener` as an `Error` from {@link defectToError}.
|
|
281
|
+
*
|
|
282
|
+
* Browsers never pass a shared worker's uncaught errors to a page's error
|
|
283
|
+
* handlers, and no worker passes its unhandled rejections to its page. A shared
|
|
284
|
+
* worker calls this with its global scope, `self`, to send them to its pages,
|
|
285
|
+
* for example over a BroadcastChannel. Defects of a Run from {@link createRun}
|
|
286
|
+
* arrive too, because it reports them with `reportError`, which dispatches
|
|
287
|
+
* "error" on the global scope.
|
|
288
|
+
*
|
|
289
|
+
* The events stay uncanceled, so the browser still logs them, and a dedicated
|
|
290
|
+
* worker still passes its uncaught errors to its page. The listeners stay until
|
|
291
|
+
* disposal, so they also receive errors after a Run's panic.
|
|
292
|
+
*
|
|
293
|
+
* ### Example
|
|
294
|
+
*
|
|
295
|
+
* ```ts
|
|
296
|
+
* import { assertEqual } from "@evolu/common";
|
|
297
|
+
* import { addUncaughtErrorListener } from "@evolu/web";
|
|
298
|
+
*
|
|
299
|
+
* const messages: Array<string> = [];
|
|
300
|
+
* // A worker passes its global scope, `self`.
|
|
301
|
+
* const scope = new EventTarget();
|
|
302
|
+
* using _listener = addUncaughtErrorListener(scope, (error) => {
|
|
303
|
+
* messages.push(error.message);
|
|
304
|
+
* });
|
|
305
|
+
*
|
|
306
|
+
* scope.dispatchEvent(
|
|
307
|
+
* Object.assign(new Event("error"), { error: new Error("unexpected") }),
|
|
308
|
+
* );
|
|
309
|
+
* assertEqual(messages, ["unexpected"]);
|
|
310
|
+
* ```
|
|
311
|
+
*/
|
|
312
|
+
export const addUncaughtErrorListener = (
|
|
313
|
+
scope: EventTarget,
|
|
314
|
+
listener: (error: Error) => void,
|
|
315
|
+
): Disposable => {
|
|
316
|
+
using disposer = new DisposableStack();
|
|
317
|
+
const { signal } = disposer.adopt(new AbortController(), (controller) => {
|
|
318
|
+
controller.abort();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
scope.addEventListener(
|
|
322
|
+
"error",
|
|
323
|
+
(event) => {
|
|
324
|
+
listener(defectToError((event as ErrorEvent).error));
|
|
325
|
+
},
|
|
326
|
+
{ signal },
|
|
327
|
+
);
|
|
328
|
+
scope.addEventListener(
|
|
329
|
+
"unhandledrejection",
|
|
330
|
+
(event) => {
|
|
331
|
+
listener(defectToError((event as PromiseRejectionEvent).reason));
|
|
332
|
+
},
|
|
333
|
+
{ signal },
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
return disposable({}, disposer);
|
|
337
|
+
};
|
|
338
|
+
|
|
274
339
|
/** Creates deps shared by web worker entry points. */
|
|
275
340
|
export const createWorkerDeps = (): WorkerDeps &
|
|
276
341
|
CreateBroadcastChannelDep &
|
|
@@ -148,26 +148,59 @@ describe("createEvoluDeps", () => {
|
|
|
148
148
|
});
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
-
describe("storage
|
|
152
|
-
const
|
|
151
|
+
describe("persistent storage request", () => {
|
|
152
|
+
const setupStorage = (isPersisted: boolean) => ({
|
|
153
|
+
persisted: mock.fn(() => Promise.resolve(isPersisted)),
|
|
154
|
+
persist: mock.fn(() => Promise.resolve(true)),
|
|
155
|
+
});
|
|
153
156
|
|
|
154
|
-
it("
|
|
155
|
-
const
|
|
156
|
-
using setup = setupWebEvoluDeps({
|
|
157
|
-
setup.connect(workerId);
|
|
157
|
+
it("asks the browser once to keep the site's data", async () => {
|
|
158
|
+
const storage = setupStorage(false);
|
|
159
|
+
using setup = setupWebEvoluDeps({ storage });
|
|
158
160
|
|
|
159
|
-
setup.
|
|
161
|
+
setup.deps.requestPersistentStorage?.();
|
|
162
|
+
setup.deps.requestPersistentStorage?.();
|
|
163
|
+
await waitForMacrotask();
|
|
160
164
|
|
|
161
|
-
assertSame(
|
|
165
|
+
assertSame(storage.persisted.mock.callCount(), 1);
|
|
166
|
+
assertSame(storage.persist.mock.callCount(), 1);
|
|
162
167
|
});
|
|
163
168
|
|
|
164
|
-
it("
|
|
165
|
-
|
|
166
|
-
setup
|
|
169
|
+
it("does not ask when the site's data is already persistent", async () => {
|
|
170
|
+
const storage = setupStorage(true);
|
|
171
|
+
using setup = setupWebEvoluDeps({ storage });
|
|
167
172
|
|
|
168
|
-
setup.
|
|
173
|
+
setup.deps.requestPersistentStorage?.();
|
|
174
|
+
await waitForMacrotask();
|
|
169
175
|
|
|
170
|
-
assertSame(
|
|
176
|
+
assertSame(storage.persist.mock.callCount(), 0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("ignores a browser that refuses to answer", async () => {
|
|
180
|
+
const storage = {
|
|
181
|
+
persisted: mock.fn(() =>
|
|
182
|
+
Promise.reject(new TypeError("Storage is disabled.")),
|
|
183
|
+
),
|
|
184
|
+
persist: mock.fn(() => Promise.resolve(true)),
|
|
185
|
+
};
|
|
186
|
+
using setup = setupWebEvoluDeps({ storage });
|
|
187
|
+
|
|
188
|
+
setup.deps.requestPersistentStorage?.();
|
|
189
|
+
await waitForMacrotask();
|
|
190
|
+
|
|
191
|
+
assertSame(storage.persist.mock.callCount(), 0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("uses the app's request instead", async () => {
|
|
195
|
+
const storage = setupStorage(false);
|
|
196
|
+
const requestPersistentStorage = mock.fn<() => void>();
|
|
197
|
+
using setup = setupWebEvoluDeps({ storage, requestPersistentStorage });
|
|
198
|
+
|
|
199
|
+
setup.deps.requestPersistentStorage?.();
|
|
200
|
+
await waitForMacrotask();
|
|
201
|
+
|
|
202
|
+
assertSame(requestPersistentStorage.mock.callCount(), 1);
|
|
203
|
+
assertSame(storage.persisted.mock.callCount(), 0);
|
|
171
204
|
});
|
|
172
205
|
});
|
|
173
206
|
|
|
@@ -443,7 +476,8 @@ const setupWebEvoluDeps = ({
|
|
|
443
476
|
isSessionStorageAvailable = true,
|
|
444
477
|
refusalReloads,
|
|
445
478
|
reloadedFor,
|
|
446
|
-
|
|
479
|
+
storage,
|
|
480
|
+
requestPersistentStorage,
|
|
447
481
|
}: {
|
|
448
482
|
hasFocus?: boolean;
|
|
449
483
|
isAutomaticReload?: boolean;
|
|
@@ -452,7 +486,9 @@ const setupWebEvoluDeps = ({
|
|
|
452
486
|
refusalReloads?: string;
|
|
453
487
|
/** The stored waiting workers, as a previous page load left them. */
|
|
454
488
|
reloadedFor?: string;
|
|
455
|
-
|
|
489
|
+
/** Stands in for `navigator.storage`. */
|
|
490
|
+
storage?: Pick<StorageManager, "persist" | "persisted">;
|
|
491
|
+
requestPersistentStorage?: () => void;
|
|
456
492
|
} = {}) => {
|
|
457
493
|
using disposer = new DisposableStack();
|
|
458
494
|
const sharedWorkerPort = createClosableNativePort<unknown>();
|
|
@@ -539,12 +575,17 @@ const setupWebEvoluDeps = ({
|
|
|
539
575
|
);
|
|
540
576
|
let hasFocus = initialHasFocus;
|
|
541
577
|
disposer.use(testStubGlobal("document", { hasFocus: () => hasFocus }));
|
|
578
|
+
if (storage) {
|
|
579
|
+
disposer.use(
|
|
580
|
+
testStubGlobal("navigator", { locks: navigator.locks, storage }),
|
|
581
|
+
);
|
|
582
|
+
}
|
|
542
583
|
const reloadApp = mock.fn<ReloadApp>();
|
|
543
584
|
|
|
544
585
|
const deps = createEvoluDeps({
|
|
545
586
|
console: createConsole({ level: "silent" }),
|
|
546
587
|
reloadApp,
|
|
547
|
-
...(
|
|
588
|
+
...(requestPersistentStorage && { requestPersistentStorage }),
|
|
548
589
|
});
|
|
549
590
|
const builds = channels.find(
|
|
550
591
|
(channel) => channel.name === buildsBroadcastChannelName,
|
|
@@ -572,6 +613,7 @@ const setupWebEvoluDeps = ({
|
|
|
572
613
|
sqliteSchema: { tables: {}, indexes: [] },
|
|
573
614
|
encryptionKey: testAppOwner.encryptionKey,
|
|
574
615
|
memoryOnly: false,
|
|
616
|
+
sharedWorkerId: createIdFromString<"SharedWorker">("worker"),
|
|
575
617
|
port: createClosableNativePort() as unknown as NativeMessagePort<
|
|
576
618
|
DbWorkerOutput,
|
|
577
619
|
DbWorkerInput
|
package/src/local-first/Evolu.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
exhaustiveCheck,
|
|
3
|
+
tryAsync,
|
|
3
4
|
trySync,
|
|
4
5
|
type ConsoleDep,
|
|
5
6
|
type ReloadApp,
|
|
@@ -10,7 +11,9 @@ import type {
|
|
|
10
11
|
SharedWorker as CommonSharedWorker,
|
|
11
12
|
CreateDbWorker,
|
|
12
13
|
DbWorkerInit,
|
|
14
|
+
Evolu,
|
|
13
15
|
EvoluDeps,
|
|
16
|
+
RequestPersistentStorageDep,
|
|
14
17
|
SharedWorkerId,
|
|
15
18
|
SharedWorkerInput,
|
|
16
19
|
SharedWorkerOutput,
|
|
@@ -38,23 +41,6 @@ export interface SharedWorkerUnsupportedDep {
|
|
|
38
41
|
readonly onSharedWorkerUnsupported: () => void;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
export interface StorageUnavailableDep {
|
|
42
|
-
/**
|
|
43
|
-
* Called when the browser offers no persistent storage, as in Safari's
|
|
44
|
-
* Private Browsing, so every database is kept in memory. See Storage in the
|
|
45
|
-
* Shared module of `@evolu/common`.
|
|
46
|
-
*
|
|
47
|
-
* The app keeps working. Data synced with a relay comes back, as on a new
|
|
48
|
-
* device, and nothing stays on the device once the tabs close. That suits
|
|
49
|
-
* someone checking their app on a borrowed phone, so a message such as
|
|
50
|
-
* "Nothing from this session is kept on this device." tells the user what to
|
|
51
|
-
* expect. Data that exists only locally, or has not synced yet, is lost when
|
|
52
|
-
* the tab hosting the database closes or navigates away, even while other
|
|
53
|
-
* tabs stay open.
|
|
54
|
-
*/
|
|
55
|
-
readonly onStorageUnavailable: () => void;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
44
|
/**
|
|
59
45
|
* Creates Evolu dependencies for the web platform.
|
|
60
46
|
*
|
|
@@ -75,9 +61,20 @@ export interface StorageUnavailableDep {
|
|
|
75
61
|
* the user comes back to it.
|
|
76
62
|
*
|
|
77
63
|
* Where the browser offers no persistent storage, as in Safari's Private
|
|
78
|
-
* Browsing, the database is kept in memory, and
|
|
79
|
-
* {@link
|
|
80
|
-
* user.
|
|
64
|
+
* Browsing or a Firefox private window, the database is kept in memory, and
|
|
65
|
+
* {@link Evolu.devicePersistence} resolves to `NotPersisted`, so the app can
|
|
66
|
+
* tell the user. Data that exists only locally, or has not synced yet, is lost
|
|
67
|
+
* when the tab hosting the database closes or navigates away, even while other
|
|
68
|
+
* tabs stay open.
|
|
69
|
+
*
|
|
70
|
+
* After the first local mutation of a database the browser stores, this tab
|
|
71
|
+
* asks the browser once with `navigator.storage.persist()` not to delete the
|
|
72
|
+
* site's data when disk space runs low. Chrome and Safari decide silently, and
|
|
73
|
+
* Firefox asks the user, so every tab asks until the user allows it. A custom
|
|
74
|
+
* {@link RequestPersistentStorageDep.requestPersistentStorage} replaces the
|
|
75
|
+
* request, for example with `constVoid` to never ask. See [Will my data stay on
|
|
76
|
+
* the
|
|
77
|
+
* device?](https://www.evolu.dev/docs/faq#will-my-data-stay-on-the-device).
|
|
81
78
|
*
|
|
82
79
|
* A custom {@link ReloadApp} replaces the default page reload, for example to
|
|
83
80
|
* save state first. It should end by reloading the page, because the other
|
|
@@ -88,7 +85,7 @@ export const createEvoluDeps = (
|
|
|
88
85
|
deps: Partial<ConsoleDep> &
|
|
89
86
|
Partial<ReloadAppDep> &
|
|
90
87
|
Partial<SharedWorkerUnsupportedDep> &
|
|
91
|
-
Partial<
|
|
88
|
+
Partial<RequestPersistentStorageDep> = {},
|
|
92
89
|
): EvoluDeps => {
|
|
93
90
|
installOneTabSharedWorkerPolyfill();
|
|
94
91
|
const reloadThisApp = deps.reloadApp ?? reloadApp;
|
|
@@ -110,6 +107,7 @@ export const createEvoluDeps = (
|
|
|
110
107
|
const earlyAnnouncedWorkerIds = new Set<SharedWorkerId>();
|
|
111
108
|
// Checks whether the user left this tab while another build waits.
|
|
112
109
|
let focusCheckId: ReturnType<typeof setInterval> | null = null;
|
|
110
|
+
let isPersistentStorageRequested = false;
|
|
113
111
|
|
|
114
112
|
using disposer = new DisposableStack();
|
|
115
113
|
const buildsBroadcastChannel = disposer.use(
|
|
@@ -225,12 +223,6 @@ export const createEvoluDeps = (
|
|
|
225
223
|
break;
|
|
226
224
|
}
|
|
227
225
|
|
|
228
|
-
case "StorageUnavailable": {
|
|
229
|
-
deps.onStorageUnavailable?.();
|
|
230
|
-
forward(message);
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
226
|
case "SharedWorkerUnsupported": {
|
|
235
227
|
if (deps.onSharedWorkerUnsupported) {
|
|
236
228
|
deps.onSharedWorkerUnsupported();
|
|
@@ -292,8 +284,18 @@ export const createEvoluDeps = (
|
|
|
292
284
|
},
|
|
293
285
|
};
|
|
294
286
|
|
|
287
|
+
const requestPersistentStorage = (): void => {
|
|
288
|
+
if (isPersistentStorageRequested) return;
|
|
289
|
+
isPersistentStorageRequested = true;
|
|
290
|
+
void tryAsync(async () => {
|
|
291
|
+
if (await navigator.storage.persisted()) return;
|
|
292
|
+
await navigator.storage.persist();
|
|
293
|
+
});
|
|
294
|
+
};
|
|
295
|
+
|
|
295
296
|
const evoluDeps = disposer.use(
|
|
296
297
|
createCommonEvoluDeps({
|
|
298
|
+
requestPersistentStorage,
|
|
297
299
|
...deps,
|
|
298
300
|
createDbWorker,
|
|
299
301
|
createBroadcastChannel,
|
|
@@ -4,34 +4,62 @@ declare const self: DedicatedWorkerGlobalScope | SharedWorkerGlobalScope;
|
|
|
4
4
|
import { installPolyfills } from "@evolu/common/polyfills";
|
|
5
5
|
installPolyfills();
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
createUnknownError,
|
|
9
|
+
createWebSocket,
|
|
10
|
+
ok,
|
|
11
|
+
tryAsync,
|
|
12
|
+
waitForAbort,
|
|
13
|
+
} from "@evolu/common";
|
|
8
14
|
import type {
|
|
15
|
+
ConsoleEntryOrError,
|
|
9
16
|
SharedWorkerInput,
|
|
10
17
|
SharedWorkerOutput,
|
|
11
18
|
} from "@evolu/common/local-first";
|
|
12
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
consoleEntryOrErrorBroadcastChannelName,
|
|
21
|
+
initSharedWorker,
|
|
22
|
+
} from "@evolu/common/local-first";
|
|
13
23
|
import type { SharedWorkerUnsupported } from "./Evolu.ts";
|
|
14
24
|
import { createRun } from "../Task.ts";
|
|
15
25
|
import {
|
|
26
|
+
addUncaughtErrorListener,
|
|
27
|
+
createBroadcastChannel,
|
|
16
28
|
createOneTabSharedWorkerSelfPolyfill,
|
|
17
29
|
createSharedWorkerSelf,
|
|
18
30
|
createWorkerDeps,
|
|
19
31
|
} from "../Worker.ts";
|
|
20
32
|
|
|
33
|
+
const errors = createBroadcastChannel<ConsoleEntryOrError>(
|
|
34
|
+
consoleEntryOrErrorBroadcastChannelName,
|
|
35
|
+
);
|
|
36
|
+
// Each tab logs an uncaught error, including a Run defect, and sets it as its
|
|
37
|
+
// evoluError.
|
|
38
|
+
addUncaughtErrorListener(self, (error) => {
|
|
39
|
+
errors.postMessage({ type: "Error", error: createUnknownError(error) });
|
|
40
|
+
});
|
|
41
|
+
|
|
21
42
|
const run = createRun({
|
|
22
43
|
...createWorkerDeps(),
|
|
23
44
|
createWebSocket,
|
|
24
|
-
// Safari's Private Browsing
|
|
25
|
-
|
|
26
|
-
|
|
45
|
+
// Safari's Private Browsing and Firefox's private windows offer no OPFS, and
|
|
46
|
+
// a browser may delete it, as Chrome's incognito does when the session ends;
|
|
47
|
+
// see Storage in the Shared module.
|
|
48
|
+
getDevicePersistence: async () =>
|
|
49
|
+
(await tryAsync(() => navigator.storage.getDirectory())).ok
|
|
50
|
+
? "Unknown"
|
|
51
|
+
: "NotPersisted",
|
|
27
52
|
lockManager: navigator.locks,
|
|
28
53
|
});
|
|
29
54
|
|
|
30
55
|
void run(async (run) => {
|
|
31
56
|
if ("onconnect" in self) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
57
|
+
// A panic closes the worker, so tabs that connect later start another.
|
|
58
|
+
using workerSelf = createSharedWorkerSelf<
|
|
59
|
+
SharedWorkerInput,
|
|
60
|
+
SharedWorkerOutput
|
|
61
|
+
>(self);
|
|
62
|
+
await using _ = await run.ok(initSharedWorker(workerSelf));
|
|
35
63
|
return await run(waitForAbort);
|
|
36
64
|
}
|
|
37
65
|
|