Skip to content

Commit ff10175

Browse files
committed
refactor: centralize error and console message reporting from worker to tab via a single message channel.
1 parent 6480804 commit ff10175

File tree

7 files changed

+178
-121
lines changed

7 files changed

+178
-121
lines changed

apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ const Schema = {
2727

2828
const deps = createEvoluDeps();
2929

30+
deps.evoluError.subscribe(() => {
31+
const error = deps.evoluError.get();
32+
if (!error) return;
33+
34+
alert("🚨 Evolu error occurred! Check the console.");
35+
// eslint-disable-next-line no-console
36+
console.error(error);
37+
});
38+
3039
// Create Evolu instance for the React web platform.
3140
const evolu = Evolu.createEvolu(deps)(Schema, {
3241
name: Evolu.SimpleName.orThrow("minimal-example"),
@@ -46,20 +55,6 @@ const evolu = Evolu.createEvolu(deps)(Schema, {
4655
// in tests via the EvoluProvider.
4756
const useEvolu = createUseEvolu(evolu);
4857

49-
/**
50-
* Subscribe to Evolu errors (database, network, sync issues). These should not
51-
* happen in normal operation, so always log them for debugging. Show users a
52-
* friendly error message instead of technical details.
53-
*/
54-
evolu.subscribeError(() => {
55-
const error = evolu.getError();
56-
if (!error) return;
57-
58-
alert("🚨 Evolu error occurred! Check the console.");
59-
// eslint-disable-next-line no-console
60-
console.error(error);
61-
});
62-
6358
export const EvoluMinimalExample: FC = () => (
6459
<div className="min-h-screen px-8 py-8">
6560
<div className="mx-auto max-w-md">

bun.lock

Lines changed: 25 additions & 55 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"@vitest/browser-playwright": "^4.0.17",
5050
"@vitest/coverage-v8": "^4.0.17",
5151
"rimraf": "^6.1.2",
52-
"turbo": "^2.8.7",
52+
"turbo": "^2.8.8",
5353
"typedoc": "^0.28.17",
5454
"typedoc-plugin-markdown": "^4.9.0",
5555
"typescript": "^5.9.3",

packages/common/src/local-first/Evolu.ts

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,20 @@
66

77
import { dedupeArray, isNonEmptyArray } from "../Array.js";
88
import { assertNonEmptyReadonlyArray } from "../Assert.js";
9-
import { type ConsoleDep, createConsole } from "../Console.js";
9+
import {
10+
type Console,
11+
type ConsoleDep,
12+
type ConsoleEntry,
13+
createConsole,
14+
} from "../Console.js";
1015
import {
1116
createRandomBytes,
1217
type EncryptionKey,
1318
type RandomBytesDep,
1419
} from "../Crypto.js";
1520
import { eqArrayNumber } from "../Eq.js";
1621
import { createUnknownError } from "../Error.js";
22+
import { exhaustiveCheck } from "../Function.js";
1723
import type { Listener, Unsubscribe } from "../Listeners.js";
1824
import type { FlushSyncDep, ReloadAppDep } from "../Platform.js";
1925
import { createDisposableDep, type DisposableStackDep } from "../Resources.js";
@@ -65,7 +71,7 @@ import {
6571
} from "./Schema.js";
6672
import type { DbChange, ValidDbChangeValues } from "./Storage.js";
6773
import type { SyncOwner } from "./Sync.js";
68-
import type { EvoluWorkerDep } from "./Worker.js";
74+
import type { EvoluTabOutput, EvoluWorkerDep } from "./Worker.js";
6975

7076
export interface EvoluConfig {
7177
/**
@@ -571,18 +577,59 @@ export type EvoluPlatformDeps = ReloadAppDep &
571577
Partial<ConsoleDep> &
572578
Partial<FlushSyncDep>;
573579

580+
const writeConsoleEntry = (console: Console, entry: ConsoleEntry): void => {
581+
const method = console[entry.method] as (
582+
...args: ReadonlyArray<unknown>
583+
) => void;
584+
method(...entry.args);
585+
};
586+
574587
/** Creates Evolu dependencies from platform-specific dependencies. */
575588
export const createEvoluDeps = <D extends EvoluPlatformDeps>(
576589
deps: D,
577590
): EvoluDeps => {
591+
const { createMessageChannel, evoluWorker } = deps as D &
592+
CreateMessageChannelDep &
593+
EvoluWorkerDep;
578594
const disposableStack = new DisposableStack();
579-
const evoluError = createErrorStore({ ...deps, disposableStack } as any);
595+
const console = deps.console ?? createConsole();
596+
const evoluError = disposableStack.use(createStore<EvoluError | null>(null));
597+
const tabChannel = disposableStack.use(
598+
createMessageChannel<EvoluTabOutput>(),
599+
);
600+
601+
disposableStack.use(evoluWorker);
602+
603+
tabChannel.port2.onMessage = (output: EvoluTabOutput) => {
604+
switch (output.type) {
605+
case "ConsoleEntry": {
606+
writeConsoleEntry(console, output.entry);
607+
if (output.entry.method === "error") {
608+
// Fallback when an error was logged without typed EvoluError payload.
609+
evoluError.set(createUnknownError(output.entry.args));
610+
}
611+
break;
612+
}
613+
case "EvoluError": {
614+
evoluError.set(output.error);
615+
console.error(output.error);
616+
break;
617+
}
618+
default:
619+
exhaustiveCheck(output);
620+
}
621+
};
622+
623+
evoluWorker.port.postMessage(
624+
{ type: "InitTab", port: tabChannel.port1.native },
625+
[tabChannel.port1.native],
626+
);
580627

581628
return {
582629
...deps,
583630
disposableStack,
584631
...createDisposableDep(disposableStack),
585-
console: deps.console ?? createConsole(),
632+
console,
586633
evoluError,
587634
randomBytes: createRandomBytes(),
588635
} as unknown as EvoluDeps;
@@ -606,28 +653,6 @@ export interface ErrorStoreDep {
606653
readonly evoluError: ReadonlyStore<EvoluError | null>;
607654
}
608655

609-
const createErrorStore = (
610-
deps: CreateMessageChannelDep & EvoluWorkerDep & DisposableStackDep,
611-
): Store<EvoluError | null> => {
612-
const errorChannel = deps.disposableStack.use(
613-
deps.createMessageChannel<EvoluError>(),
614-
);
615-
const evoluError = deps.disposableStack.use(
616-
createStore<EvoluError | null>(null),
617-
);
618-
619-
deps.evoluWorker.port.postMessage(
620-
{ type: "InitErrorStore", port: errorChannel.port1.native },
621-
[errorChannel.port1.native],
622-
);
623-
624-
errorChannel.port2.onMessage = (error) => {
625-
evoluError.set(error);
626-
};
627-
628-
return evoluError;
629-
};
630-
631656
/**
632657
* Creates an {@link Evolu} instance for a platform configured with the specified
633658
* {@link EvoluSchema} and optional {@link EvoluConfig} providing a typed
@@ -681,10 +706,10 @@ export const createEvolu =
681706
} = config;
682707
const name =
683708
configName ??
684-
appName ??
709+
(appName ? SimpleName.orThrow(appName) : undefined) ??
685710
SimpleName.orThrow("default");
686711

687-
const errorStore = createStore<EvoluError | null>(null);
712+
const errorStore = deps.evoluError as Store<EvoluError | null>;
688713
const rowsStore = createStore<QueryRowsMap>(new Map());
689714
const subscribedQueries = createSubscribedQueries(rowsStore);
690715
const loadingPromises = createLoadingPromises(subscribedQueries);

packages/common/src/local-first/Worker.ts

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
* @module
55
*/
66

7+
import type { ConsoleEntry } from "../Console.js";
78
import { exhaustiveCheck } from "../Function.js";
89
import { ok } from "../Result.js";
910
import type { Task } from "../Task.js";
10-
import type { Typed } from "../Type.js";
1111
import type {
1212
SharedWorker as CommonSharedWorker,
1313
CreateMessagePortDep,
@@ -24,15 +24,25 @@ export interface EvoluWorkerDep {
2424
readonly evoluWorker: EvoluWorker;
2525
}
2626

27-
export interface InitErrorStoreMessage extends Typed<"InitErrorStore"> {
28-
readonly port: NativeMessagePort;
29-
}
30-
31-
export interface InitEvoluMessage extends Typed<"InitEvolu"> {
32-
readonly port: NativeMessagePort;
33-
}
27+
export type EvoluWorkerInput =
28+
| {
29+
readonly type: "InitTab";
30+
readonly port: NativeMessagePort;
31+
}
32+
| {
33+
readonly type: "InitEvolu";
34+
readonly port: NativeMessagePort;
35+
};
3436

35-
export type EvoluWorkerInput = InitErrorStoreMessage | InitEvoluMessage;
37+
export type EvoluTabOutput =
38+
| {
39+
readonly type: "ConsoleEntry";
40+
readonly entry: ConsoleEntry;
41+
}
42+
| {
43+
readonly type: "EvoluError";
44+
readonly error: EvoluError;
45+
};
3646

3747
export interface RunDbWorkerPortDep {
3848
readonly runDbWorkerPort: (
@@ -43,19 +53,35 @@ export interface RunDbWorkerPortDep {
4353
export const runEvoluWorkerScope =
4454
(deps: CreateMessagePortDep & RunDbWorkerPortDep) =>
4555
(self: EvoluWorkerScope<EvoluWorkerInput>): void => {
46-
const errorStorePorts = new Set<MessagePort<EvoluError>>();
56+
const tabPorts = new Set<MessagePort<EvoluTabOutput>>();
57+
const queuedTabOutputs: Array<EvoluTabOutput> = [];
58+
59+
const postTabOutput = (output: EvoluTabOutput): void => {
60+
if (tabPorts.size === 0) {
61+
queuedTabOutputs.push(output);
62+
return;
63+
}
64+
for (const port of tabPorts) port.postMessage(output);
65+
};
4766

4867
self.onError = (error) => {
49-
for (const port of errorStorePorts) port.postMessage(error);
68+
postTabOutput({ type: "EvoluError", error });
5069
};
5170

5271
self.onConnect = (port) => {
5372
port.onMessage = (message) => {
5473
switch (message.type) {
55-
case "InitErrorStore": {
56-
errorStorePorts.add(
57-
deps.createMessagePort<EvoluError>(message.port),
74+
case "InitTab": {
75+
const tabPort = deps.createMessagePort<EvoluTabOutput>(
76+
message.port,
5877
);
78+
tabPorts.add(tabPort);
79+
80+
if (queuedTabOutputs.length > 0) {
81+
for (const output of queuedTabOutputs)
82+
tabPort.postMessage(output);
83+
queuedTabOutputs.length = 0;
84+
}
5985
break;
6086
}
6187
case "InitEvolu": {

packages/common/test/local-first/Evolu.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, test } from "vitest";
22
// Force rebuild
33
import { testCreateConsole } from "../../src/Console.js";
4+
import { createUnknownError } from "../../src/Error.js";
45
import { lazyVoid } from "../../src/Function.js";
56
import type {
67
DbWorkerInput,
@@ -13,6 +14,7 @@ import {
1314
} from "../../src/local-first/Evolu.js";
1415
import type { AppOwner } from "../../src/local-first/Owner.js";
1516
import { createQueryBuilder } from "../../src/local-first/Schema.js";
17+
import type { EvoluTabOutput } from "../../src/local-first/Worker.js";
1618
import { SqliteBoolean } from "../../src/Sqlite.js";
1719
import { testCreateRun } from "../../src/Test.js";
1820
import { id, NonEmptyString100, nullOr } from "../../src/Type.js";
@@ -94,6 +96,7 @@ const createMockDeps = () => {
9496
let resetCount = 0;
9597
let mutateCount = 0;
9698
let closeCount = 0;
99+
let tabPort: MockPort<EvoluTabOutput, never> | null = null;
97100

98101
const deps = {
99102
reloadApp: () => {
@@ -103,9 +106,14 @@ const createMockDeps = () => {
103106
evoluWorker: {
104107
port: {
105108
postMessage: (
106-
message: { type: "InitEvolu"; port: unknown },
109+
message: { type: "InitTab" | "InitEvolu"; port: unknown },
107110
_transfer?: ReadonlyArray<unknown>,
108111
) => {
112+
if (message.type === "InitTab") {
113+
tabPort = message.port as MockPort<EvoluTabOutput, never>;
114+
return;
115+
}
116+
109117
if (message.type !== "InitEvolu") return;
110118
const dbPort = message.port as MockPort<
111119
DbWorkerOutput,
@@ -208,16 +216,21 @@ const createMockDeps = () => {
208216
native: {} as unknown,
209217
[Symbol.dispose]: () => {},
210218
},
219+
[Symbol.dispose]: () => {},
211220
},
212221
};
213222

214223
return {
215224
deps,
225+
emitTabOutput: (output: EvoluTabOutput) => {
226+
tabPort?.postMessage(output);
227+
},
216228
getState: () => ({
217229
reloadCount,
218230
resetCount,
219231
mutateCount,
220232
closeCount,
233+
hasTabPort: tabPort !== null,
221234
rows: [...rows],
222235
}),
223236
};
@@ -235,6 +248,34 @@ test("createEvoluDeps keeps provided console instance", () => {
235248
expect(evoluDeps.console).toBe(console);
236249
});
237250

251+
test("createEvoluDeps updates evoluError store from tab output", () => {
252+
const { deps, emitTabOutput, getState } = createMockDeps();
253+
const evoluDeps = createEvoluDeps(deps as any);
254+
255+
expect(getState().hasTabPort).toBe(true);
256+
257+
const error = createUnknownError(new Error("tab-boom"));
258+
emitTabOutput({ type: "EvoluError", error });
259+
260+
expect(evoluDeps.evoluError.get()).toEqual(error);
261+
});
262+
263+
test("createEvoluDeps wraps console error entry into UnknownError", () => {
264+
const { deps, emitTabOutput } = createMockDeps();
265+
const evoluDeps = createEvoluDeps(deps as any);
266+
267+
emitTabOutput({
268+
type: "ConsoleEntry",
269+
entry: {
270+
method: "error",
271+
path: ["mock-worker"],
272+
args: ["boom"],
273+
},
274+
});
275+
276+
expect(evoluDeps.evoluError.get()).toEqual(createUnknownError(["boom"]));
277+
});
278+
238279
describe("createEvolu", () => {
239280
test("appOwner from config is exposed as evolu.appOwner", async () => {
240281
const { deps } = createMockDeps();

0 commit comments

Comments
 (0)