From fc99998fc24a34ef2720c47bb0275708dbc8bc37 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Fri, 13 Feb 2026 23:56:08 +0100 Subject: [PATCH 01/60] Add evoluError tab channel and error handling Introduce a tab message channel (InitTab) and typed EvoluTabOutput to carry ConsoleEntry and EvoluError messages between the worker and the UI. createEvoluDeps now creates and exposes a read-only evoluError store, wires tab channel outputs into console logging and the evoluError store (including fallback wrapping for untyped console.error entries), and returns a disposable deps object that disposes used resources. The worker init was updated to queue and broadcast EvoluTabOutput values, and EvoluWorker/Worker input types were adjusted accordingly. Tests and a minimal playground example were updated to reflect the new InitTab flow and to assert evoluError behavior. Also removed the now-unused DisposableStackDep/AsyncDisposableStackDep interfaces from Types.ts. --- .../minimal/EvoluMinimalExample.tsx | 7 + packages/common/src/Types.ts | 10 -- packages/common/src/local-first/Evolu.ts | 63 ++++++-- packages/common/src/local-first/Worker.ts | 68 ++++---- .../common/test/local-first/Evolu.test.ts | 118 ++++++++++++-- .../common/test/local-first/Worker.test.ts | 151 +++++++++++++++--- 6 files changed, 340 insertions(+), 77 deletions(-) diff --git a/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx b/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx index ef178ce1f..e0b1fc2d4 100644 --- a/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx +++ b/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx @@ -51,6 +51,13 @@ const console = Evolu.createConsole({ // Create Evolu dependencies for React Web. const deps = createEvoluDeps({ console }); +deps.evoluError.subscribe(() => { + const error = deps.evoluError.get(); + if (!error) return; + + alert("🚨 Evolu error occurred! Check the console."); +}); + const run = createRun(deps); // Create Evolu App. diff --git a/packages/common/src/Types.ts b/packages/common/src/Types.ts index 71198c582..0c214094e 100644 --- a/packages/common/src/Types.ts +++ b/packages/common/src/Types.ts @@ -342,13 +342,3 @@ export type DistributiveOmit = T extends unknown export type NewKeys = { [K in keyof T]: K extends keyof Existing ? never : T[K]; }; - -/** Dependency exposing a shared {@link DisposableStack}. */ -export interface DisposableStackDep { - readonly disposableStack: DisposableStack; -} - -/** Dependency exposing a shared {@link AsyncDisposableStack}. */ -export interface AsyncDisposableStackDep { - readonly asyncDisposableStack: AsyncDisposableStack; -} diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index 41b572b5b..1baa46a0a 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -4,16 +4,20 @@ * @module */ -import type { ConsoleDep, ConsoleEntry } from "../Console.js"; +import type { ConsoleDep } from "../Console.js"; import { createConsole } from "../Console.js"; +import { createUnknownError } from "../Error.js"; +import { exhaustiveCheck } from "../Function.js"; import type { Listener, Unsubscribe } from "../Listeners.js"; import type { FlushSyncDep, ReloadAppDep } from "../Platform.js"; import { err, ok } from "../Result.js"; +import type { ReadonlyStore } from "../Store.js"; +import { createStore } from "../Store.js"; import type { Task } from "../Task.js"; import type { Id, TypeError } from "../Type.js"; import { brand, createIdFromString, Name, UrlSafeString } from "../Type.js"; -import type { DisposableStackDep } from "../Types.js"; import type { CreateMessageChannelDep } from "../Worker.js"; +import type { EvoluError } from "./Error.js"; import type { AppOwner, OwnerTransport } from "./Owner.js"; import { createAppOwner, @@ -36,7 +40,7 @@ import type { } from "./Schema.js"; import type { SyncOwner } from "./Sync.js"; import type { Timestamp } from "./Timestamp.js"; -import type { EvoluWorkerDep } from "./Worker.js"; +import type { EvoluTabOutput, EvoluWorkerDep } from "./Worker.js"; export interface EvoluConfig { /** @@ -382,7 +386,11 @@ export interface Evolu< /** Function returned by {@link Evolu.useOwner} to stop using an {@link SyncOwner}. */ export type UnuseOwner = () => void; -export type EvoluDeps = EvoluPlatformDeps & DisposableStackDep; +export interface EvoluErrorDep { + readonly evoluError: ReadonlyStore; +} + +export type EvoluDeps = EvoluPlatformDeps & EvoluErrorDep & Disposable; export type EvoluPlatformDeps = CreateMessageChannelDep & EvoluWorkerDep & @@ -390,21 +398,54 @@ export type EvoluPlatformDeps = CreateMessageChannelDep & Partial & Partial; -/** Creates Evolu dependencies from platform-specific dependencies. */ +/** + * Creates Evolu dependencies from platform-specific dependencies. Returns a + * disposable deps object that disposes all used resources. + */ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => { const { createMessageChannel, evoluWorker } = deps; const console = deps.console ?? createConsole(); - const stack = new DisposableStack(); + const evoluError = createStore(null); - const consoleChannel = stack.use(createMessageChannel()); - consoleChannel.port2.onMessage = console.write; + const stack = new DisposableStack(); + stack.use(evoluWorker); + + const tabChannel = stack.use(createMessageChannel()); + tabChannel.port2.onMessage = (output) => { + switch (output.type) { + case "ConsoleEntry": { + console.write(output.entry); + + // Fallback channel for unexpected errors without EvoluError typing. + if (output.entry.method === "error") { + evoluError.set(createUnknownError(output.entry.args)); + } + break; + } + case "EvoluError": { + evoluError.set(output.error); + + // Keep typed errors visible in logs as operational failures. + console.error(output.error); + break; + } + default: + exhaustiveCheck(output); + } + }; evoluWorker.port.postMessage( - { type: "InitConsole", port: consoleChannel.port1.native }, - [consoleChannel.port1.native], + { type: "InitTab", port: tabChannel.port1.native }, + [tabChannel.port1.native], ); - return { ...deps, disposableStack: stack.move() }; + const movedStack = stack.move(); + + return { + ...deps, + evoluError, + [Symbol.dispose]: movedStack[Symbol.dispose].bind(movedStack), + }; }; /** diff --git a/packages/common/src/local-first/Worker.ts b/packages/common/src/local-first/Worker.ts index 22bef8f84..5e9c36ad8 100644 --- a/packages/common/src/local-first/Worker.ts +++ b/packages/common/src/local-first/Worker.ts @@ -8,30 +8,40 @@ import type { ConsoleEntry, ConsoleStoreOutputEntryDep } from "../Console.js"; import { exhaustiveCheck } from "../Function.js"; import { ok } from "../Result.js"; import type { Task } from "../Task.js"; -import type { Typed } from "../Type.js"; import type { - SharedWorker as CommonSharedWorker, CreateMessagePortDep, MessagePort, NativeMessagePort, + SharedWorker, SharedWorkerSelf, } from "../Worker.js"; +import type { EvoluError } from "./Error.js"; -export type EvoluWorker = CommonSharedWorker; +export type EvoluWorker = SharedWorker; export interface EvoluWorkerDep { readonly evoluWorker: EvoluWorker; } -export type EvoluWorkerInput = InitConsoleMessage | InitEvoluMessage; - -export interface InitConsoleMessage extends Typed<"InitConsole"> { - readonly port: NativeMessagePort; -} +export type EvoluWorkerInput = + | { + readonly type: "InitTab"; + readonly port: NativeMessagePort; + } + | { + readonly type: "InitEvolu"; + readonly port: NativeMessagePort; + }; -export interface InitEvoluMessage extends Typed<"InitEvolu"> { - readonly port: NativeMessagePort; -} +export type EvoluTabOutput = + | { + readonly type: "ConsoleEntry"; + readonly entry: ConsoleEntry; + } + | { + readonly type: "EvoluError"; + readonly error: EvoluError; + }; export const initEvoluWorker = ( @@ -46,10 +56,16 @@ export const initEvoluWorker = const console = run.deps.console.child("EvoluWorker"); // TODO: Use heartbeat to detect and prune dead ports. - const consolePorts = new Set>(); - const queuedConsoleEntries: Array = []; - const broadcastConsoleEntry = (entry: ConsoleEntry): void => { - for (const port of consolePorts) port.postMessage(entry); + const tabPorts = new Set>(); + const queuedTabOutputs: Array = []; + + const postTabOutput = (output: EvoluTabOutput): void => { + for (const port of tabPorts) port.postMessage(output); + }; + + const postOrQueueTabOutput = (output: EvoluTabOutput): void => { + if (tabPorts.size === 0) queuedTabOutputs.push(output); + else postTabOutput(output); }; await using stack = run.stack(); @@ -57,14 +73,7 @@ export const initEvoluWorker = stack.defer( consoleStoreOutputEntry.subscribe(() => { const entry = consoleStoreOutputEntry.get(); - if (!entry) return; - - if (consolePorts.size === 0) { - queuedConsoleEntries.push(entry); - return; - } - - broadcastConsoleEntry(entry); + if (entry) postOrQueueTabOutput({ type: "ConsoleEntry", entry }); }), ); @@ -75,13 +84,13 @@ export const initEvoluWorker = port.onMessage = (message) => { switch (message.type) { - case "InitConsole": { - const consolePort = createMessagePort(message.port); - consolePorts.add(consolePort); + case "InitTab": { + const tabPort = createMessagePort(message.port); + tabPorts.add(tabPort); - if (queuedConsoleEntries.length > 0) { - queuedConsoleEntries.forEach(broadcastConsoleEntry); - queuedConsoleEntries.length = 0; + if (queuedTabOutputs.length > 0) { + queuedTabOutputs.forEach(postTabOutput); + queuedTabOutputs.length = 0; } break; @@ -91,6 +100,7 @@ export const initEvoluWorker = // then set onMessage to start processing requests. // Messages are queued until onMessage is assigned. const _evoluPort = createMessagePort(message.port); + // _evoluPort.onMessage() break; } default: diff --git a/packages/common/test/local-first/Evolu.test.ts b/packages/common/test/local-first/Evolu.test.ts index b5f228be0..74f363e56 100644 --- a/packages/common/test/local-first/Evolu.test.ts +++ b/packages/common/test/local-first/Evolu.test.ts @@ -9,8 +9,9 @@ import { createEvoluDeps, } from "../../src/local-first/Evolu.js"; import type { + EvoluTabOutput, + EvoluWorker, EvoluWorkerInput, - InitConsoleMessage, } from "../../src/local-first/Worker.js"; import { err, ok } from "../../src/Result.js"; import { SqliteBoolean } from "../../src/Sqlite.js"; @@ -94,37 +95,126 @@ describe("createEvoluDeps", () => { return { messages }; }; - test("posts InitConsole with port to worker", () => { + test("posts InitTab with port to worker", () => { const { messages } = setupAndCall(testCreateConsole()); expect(messages).toHaveLength(1); - expect(messages[0].type).toBe("InitConsole"); + expect(messages[0].type).toBe("InitTab"); }); test("falls back to default console when not provided", () => { const { messages } = setupAndCall(); expect(messages).toHaveLength(1); - expect(messages[0].type).toBe("InitConsole"); + expect(messages[0].type).toBe("InitTab"); }); + const setupDepsWithPort = () => { + const { worker, self, connect } = + testCreateSharedWorker(); + const messages: Array = []; + self.onConnect = (port) => { + port.onMessage = (message) => messages.push(message); + }; + connect(); + + const deps = createEvoluDeps({ + createMessageChannel: testCreateMessageChannel, + evoluWorker: worker, + reloadApp: lazyVoid, + }); + + const initTab = messages[0] as Extract< + EvoluWorkerInput, + { readonly type: "InitTab" } + >; + const workerPort = testCreateMessagePort(initTab.port); + + return { deps, workerPort }; + }; + test("wires console channel to console.write", () => { const console = testCreateConsole(); const { messages } = setupAndCall(console); - const initConsole = messages[0] as InitConsoleMessage; - const workerPort = testCreateMessagePort(initConsole.port); + const initConsole = messages[0] as Extract< + EvoluWorkerInput, + { readonly type: "InitTab" } + >; + const workerPort = testCreateMessagePort(initConsole.port); const entry: ConsoleEntry = { method: "info", path: ["test"], args: ["hello"], }; - workerPort.postMessage(entry); + workerPort.postMessage({ type: "ConsoleEntry", entry }); expect(console.getEntriesSnapshot()).toEqual([entry]); }); + test("maps ConsoleEntry error output to deps.evoluError store", () => { + const { deps, workerPort } = setupDepsWithPort(); + + const entry: ConsoleEntry = { + method: "error", + path: ["global"], + args: ["error", { type: "UnknownError", error: "boom" }], + }; + + workerPort.postMessage({ type: "ConsoleEntry", entry }); + + expect(deps.evoluError.get()).toEqual({ + type: "UnknownError", + error: ["error", { type: "UnknownError", error: "boom" }], + }); + }); + + test("wraps single-arg ConsoleEntry error output to UnknownError", () => { + const { deps, workerPort } = setupDepsWithPort(); + + workerPort.postMessage({ + type: "ConsoleEntry", + entry: { method: "error", path: ["global"], args: ["boom"] }, + }); + + expect(deps.evoluError.get()).toEqual({ + type: "UnknownError", + error: ["boom"], + }); + }); + + test("wraps multi-arg ConsoleEntry error output to UnknownError", () => { + const { deps, workerPort } = setupDepsWithPort(); + + workerPort.postMessage({ + type: "ConsoleEntry", + entry: { method: "error", path: ["global"], args: ["error", "boom"] }, + }); + + expect(deps.evoluError.get()).toEqual({ + type: "UnknownError", + error: ["error", "boom"], + }); + }); + + test("wires EvoluError output to deps.evoluError store", () => { + const { deps, workerPort } = setupDepsWithPort(); + + const error = { type: "UnknownError", error: "boom" } as const; + workerPort.postMessage({ type: "EvoluError", error }); + + expect(deps.evoluError.get()).toEqual(error); + }); + + test("throws for unknown tab output type", () => { + const { workerPort } = setupDepsWithPort(); + + expect(() => { + workerPort.postMessage({ type: "Unknown" } as never); + }).toThrow(); + }); + test("dispose cleans up resources", () => { const { worker, self, connect } = testCreateSharedWorker(); @@ -134,6 +224,14 @@ describe("createEvoluDeps", () => { connect(); const channels: Array<{ readonly isDisposed: () => boolean }> = []; + let workerDisposed = false; + const evoluWorker: EvoluWorker = { + port: worker.port, + [Symbol.dispose]: () => { + workerDisposed = true; + worker[Symbol.dispose](); + }, + }; const deps = createEvoluDeps({ createMessageChannel: () => { @@ -141,13 +239,15 @@ describe("createEvoluDeps", () => { channels.push(channel); return channel; }, - evoluWorker: worker, + evoluWorker, reloadApp: lazyVoid, }); expect(channels[0].isDisposed()).toBe(false); - deps.disposableStack[Symbol.dispose](); + expect(workerDisposed).toBe(false); + deps[Symbol.dispose](); expect(channels[0].isDisposed()).toBe(true); + expect(workerDisposed).toBe(true); }); }); diff --git a/packages/common/test/local-first/Worker.test.ts b/packages/common/test/local-first/Worker.test.ts index 692e0bb5b..d9db994fc 100644 --- a/packages/common/test/local-first/Worker.test.ts +++ b/packages/common/test/local-first/Worker.test.ts @@ -2,6 +2,7 @@ import { createStore } from "../../src/Store.js"; import type { ConsoleEntry } from "../../src/Console.js"; import type { ReadonlyStore } from "../../src/Store.js"; import { + type EvoluTabOutput, type EvoluWorkerInput, initEvoluWorker, } from "../../src/local-first/Worker.js"; @@ -54,14 +55,14 @@ describe("initEvoluWorker", () => { consoleStoreOutputEntry.set(firstEntry); - const receivedEntries: Array = []; - const consoleChannel = testCreateMessageChannel(); - consoleChannel.port2.onMessage = (entry) => { - receivedEntries.push(entry); + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); }; worker.port.postMessage({ - type: "InitConsole", + type: "InitTab", port: consoleChannel.port1.native, }); @@ -73,7 +74,10 @@ describe("initEvoluWorker", () => { consoleStoreOutputEntry.set(secondEntry); - expect(receivedEntries).toEqual([firstEntry, secondEntry]); + expect(receivedOutputs).toEqual([ + { type: "ConsoleEntry", entry: firstEntry }, + { type: "ConsoleEntry", entry: secondEntry }, + ]); }); test("forwards entries immediately when console port is already connected", async () => { @@ -81,14 +85,14 @@ describe("initEvoluWorker", () => { const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); await using _workerStack = workerStack; - const receivedEntries: Array = []; - const consoleChannel = testCreateMessageChannel(); - consoleChannel.port2.onMessage = (entry) => { - receivedEntries.push(entry); + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); }; worker.port.postMessage({ - type: "InitConsole", + type: "InitTab", port: consoleChannel.port1.native, }); @@ -100,7 +104,9 @@ describe("initEvoluWorker", () => { consoleStoreOutputEntry.set(liveEntry); - expect(receivedEntries).toEqual([liveEntry]); + expect(receivedOutputs).toEqual([ + { type: "ConsoleEntry", entry: liveEntry }, + ]); }); test("ignores null console store updates", async () => { @@ -108,14 +114,14 @@ describe("initEvoluWorker", () => { const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); await using _workerStack = workerStack; - const receivedEntries: Array = []; - const consoleChannel = testCreateMessageChannel(); - consoleChannel.port2.onMessage = (entry) => { - receivedEntries.push(entry); + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); }; worker.port.postMessage({ - type: "InitConsole", + type: "InitTab", port: consoleChannel.port1.native, }); @@ -128,7 +134,116 @@ describe("initEvoluWorker", () => { consoleStoreOutputEntry.set(entry); consoleStoreOutputEntry.set(null); - expect(receivedEntries).toEqual([entry]); + expect(receivedOutputs).toEqual([{ type: "ConsoleEntry", entry }]); + }); + + test("forwards typed console error entries as ConsoleEntry", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + port: consoleChannel.port1.native, + }); + + const error = { type: "UnknownError", error: "boom" } as const; + const entry: ConsoleEntry = { + method: "error", + path: ["global"], + args: ["error", error], + }; + + consoleStoreOutputEntry.set(entry); + + expect(receivedOutputs).toEqual([{ type: "ConsoleEntry", entry }]); + }); + + test("forwards untyped console error entries as ConsoleEntry", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + port: consoleChannel.port1.native, + }); + + const entry: ConsoleEntry = { + method: "error", + path: ["global"], + args: ["error", "plain string"], + }; + + consoleStoreOutputEntry.set(entry); + + expect(receivedOutputs).toEqual([{ type: "ConsoleEntry", entry }]); + }); + + test("forwards console error entry with one argument", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + port: consoleChannel.port1.native, + }); + + const entry: ConsoleEntry = { + method: "error", + path: ["global"], + args: ["plain string"], + }; + + consoleStoreOutputEntry.set(entry); + + expect(receivedOutputs).toEqual([{ type: "ConsoleEntry", entry }]); + }); + + test("forwards console error entry with no arguments", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + port: consoleChannel.port1.native, + }); + + const entry: ConsoleEntry = { + method: "error", + path: ["global"], + args: [], + }; + + consoleStoreOutputEntry.set(entry); + + expect(receivedOutputs).toEqual([{ type: "ConsoleEntry", entry }]); }); test("accepts InitEvolu message", async () => { From f16f5633c974852d2c919008d7cc8690e6a6d2e7 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 10:34:42 +0100 Subject: [PATCH 02/60] Only transpile workspace packages in dev --- apps/web/next.config.mjs | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index e99033096..e48429f7b 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -13,33 +13,31 @@ const withMDX = nextMDX({ }, }); +const isDev = globalThis.process?.env.NODE_ENV === "development"; + /** @type {import("next").NextConfig} */ const nextConfig = { reactStrictMode: true, pageExtensions: ["js", "jsx", "ts", "tsx", "mdx"], // Resolve workspace @evolu/* packages to TypeScript source for live reload. - // The "source" condition matches the export added to each package.json. - // The extensionAlias maps .js imports to .ts since packages use NodeNext - // module resolution with explicit .js extensions (required for npm publishing). - transpilePackages: [ - "@evolu/common", - "@evolu/web", - "@evolu/react", - "@evolu/react-web", - ], + transpilePackages: isDev + ? ["@evolu/common", "@evolu/web", "@evolu/react", "@evolu/react-web"] + : [], webpack(config) { - config.resolve.conditionNames = [ - "source", - "browser", - "import", - "module", - "require", - "default", - ]; - config.resolve.extensionAlias = { - ".js": [".ts", ".tsx", ".js"], - }; + if (isDev) { + config.resolve.conditionNames = [ + "source", + "browser", + "import", + "module", + "require", + "default", + ]; + config.resolve.extensionAlias = { + ".js": [".ts", ".tsx", ".js"], + }; + } return config; }, From c3e32b0d2826614d8f0bdf880fd61dff6c9c17d0 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 13:59:35 +0100 Subject: [PATCH 03/60] Update pnpm-lock.yaml --- pnpm-lock.yaml | 141 +++++++++++++++++++++++++------------------------ 1 file changed, 71 insertions(+), 70 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0279fc63e..312024e66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,13 +81,13 @@ importers: version: 6.1.2 turbo: specifier: ^2.8.1 - version: 2.8.7 + version: 2.8.8 typedoc: specifier: ^0.28.16 - version: 0.28.16(typescript@5.9.3) + version: 0.28.17(typescript@5.9.3) typedoc-plugin-markdown: specifier: ^4.9.0 - version: 4.10.0(typedoc@0.28.16(typescript@5.9.3)) + version: 4.10.0(typedoc@0.28.17(typescript@5.9.3)) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -622,16 +622,16 @@ importers: version: link:../../packages/web '@sveltejs/vite-plugin-svelte': specifier: ^6.2.4 - version: 6.2.4(svelte@5.50.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)) + version: 6.2.4(svelte@5.51.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)) '@tsconfig/svelte': specifier: ^5.0.6 version: 5.0.7 svelte: specifier: ^5.48.2 - version: 5.50.3 + version: 5.51.0 svelte-check: specifier: ^4.3.6 - version: 4.3.6(picomatch@4.0.3)(svelte@5.50.3)(typescript@5.9.3) + version: 4.4.0(picomatch@4.0.3)(svelte@5.51.0)(typescript@5.9.3) tslib: specifier: ^2.8.1 version: 2.8.1 @@ -874,16 +874,16 @@ importers: version: link:../web '@sveltejs/package': specifier: ^2.5.7 - version: 2.5.7(svelte@5.50.3)(typescript@5.9.3) + version: 2.5.7(svelte@5.51.0)(typescript@5.9.3) '@tsconfig/svelte': specifier: ^5.0.6 version: 5.0.7 svelte: specifier: ^5.48.2 - version: 5.50.3 + version: 5.51.0 svelte-check: specifier: ^4.3.6 - version: 4.3.6(picomatch@4.0.3)(svelte@5.50.3)(typescript@5.9.3) + version: 4.4.0(picomatch@4.0.3)(svelte@5.51.0)(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -9360,22 +9360,22 @@ packages: resolution: {integrity: sha512-6+ZiCVUmDLFRyYRswTrDTYWaM/IT01W/cqQBLnnyg8T0njVrWj3tP+EXFevXk6qK61yDXnmZsOFVzFfYoUy/KA==} engines: {node: '>= 0.4'} - svelte-check@4.3.6: - resolution: {integrity: sha512-uBkz96ElE3G4pt9E1Tw0xvBfIUQkeH794kDQZdAUk795UVMr+NJZpuFSS62vcmO/DuSalK83LyOwhgWq8YGU1Q==} + svelte-check@4.4.0: + resolution: {integrity: sha512-gB3FdEPb8tPO3Y7Dzc6d/Pm/KrXAhK+0Fk+LkcysVtupvAh6Y/IrBCEZNupq57oh0hcwlxCUamu/rq7GtvfSEg==} engines: {node: '>= 18.0.0'} hasBin: true peerDependencies: svelte: ^4.0.0 || ^5.0.0-next.0 typescript: '>=5.0.0' - svelte2tsx@0.7.47: - resolution: {integrity: sha512-1aw/MFKVPM96OBevJdC12do2an9t5Zwr3Va9amLgTLpJje36ibD1iIHpuqCYWUrdR9vw6g6btKGQPmsqE8ZYCw==} + svelte2tsx@0.7.48: + resolution: {integrity: sha512-B15C8dtOY6C9MbnQJDCkzbK3yByInzKtXrr23QCoF8APHMh6JaDhjCMcRl6ay4qaeKYqkX4X3tNaJrsZL45Zlg==} peerDependencies: svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 typescript: ^4.9.4 || ^5.0.0 - svelte@5.50.3: - resolution: {integrity: sha512-5JCO8P/cFlwyfi1LeZ9uppMZvuaHWygyZmqxyKOIqbV3PoHKaddvV1C6njL/InpDXplNYZnAVEbn8mLslycBxQ==} + svelte@5.51.0: + resolution: {integrity: sha512-TOtjs5SjPi1iCXJ7aHwQBY+PEyk0koIc1FCKNFyS9kQuN4FBma9nRuxM4f4A0b5SCdej812vf2Rcql2I0s9FZg==} engines: {node: '>=18'} tabbable@6.4.0: @@ -9544,38 +9544,38 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - turbo-darwin-64@2.8.7: - resolution: {integrity: sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A==} + turbo-darwin-64@2.8.8: + resolution: {integrity: sha512-7R/XRAleyNB8nIJuenRpS7EnmCM/kYFgcG1rW3F5DF57Nl4WyBO3DzxqwooRheuoXDD1UsJvDse0yRufgxjClA==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.8.7: - resolution: {integrity: sha512-p8Xbmb9kZEY/NoshQUcFmQdO80s2PCGoLYj5DbpxjZr3diknipXxzOK7pcmT7l2gNHaMCpFVWLkiFY9nO3EU5w==} + turbo-darwin-arm64@2.8.8: + resolution: {integrity: sha512-whpveO0w/dMcgQ0zFrzqK76mG2W+RBQ/pKIpW+sEvNr+T4UcYd2gZpfV2euehJLvdeFErWgW74dIt/MFfkG1bA==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.8.7: - resolution: {integrity: sha512-nwfEPAH3m5y/nJeYly3j1YJNYU2EG5+2ysZUxvBNM+VBV2LjQaLxB9CsEIpIOKuWKCjnFHKIADTSDPZ3D12J5Q==} + turbo-linux-64@2.8.8: + resolution: {integrity: sha512-BISkcKk+5/Dh11q+YhdQIGONXmdlQ6LY0iQ+GBIK7QOBAJyJFKmKvUeAeMdWGXOa3sImz2oZ6u2StyR+0Y/VVA==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.8.7: - resolution: {integrity: sha512-mgA/M6xiJzyxtXV70TtWGDPh+I6acOKmeQGtOzbFQZYEf794pu5jax26bCk5skAp1gqZu3vacPr6jhYHoHU9IQ==} + turbo-linux-arm64@2.8.8: + resolution: {integrity: sha512-B+DQWWtM3O+AexFF4aYucT0MvvnQOW4n5y9LA5h3Zc3Lpj10yl9K4dEBzJvMVQ2jTkCRNAvA8q9G3EjqmGmGGA==} cpu: [arm64] os: [linux] - turbo-windows-64@2.8.7: - resolution: {integrity: sha512-sHTYMaXuCcyHnGUQgfUUt7S8407TWoP14zc/4N2tsM0wZNK6V9h4H2t5jQPtqKEb6Fg8313kygdDgEwuM4vsHg==} + turbo-windows-64@2.8.8: + resolution: {integrity: sha512-KP5TRVrmVnrxDyvERvQq7VXu+h5AiEiBwyr90YsRuYy/Z5zLz8hrNZXejTyUR17xmnuI+RbUSxrQlfmPnxiW1A==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.8.7: - resolution: {integrity: sha512-WyGiOI2Zp3AhuzVagzQN+T+iq0fWx0oGxDfAWT3ZiLEd4U0cDUkwUZDKVGb3rKqPjDL6lWnuxKKu73ge5xtovQ==} + turbo-windows-arm64@2.8.8: + resolution: {integrity: sha512-X45sTOksR8iidk+PZUXpNcfEkOhGCTS2p2J8nSUExr1v7u3T4FixwplzliuWmG7QTPGJapAwItzHbi+d/9ckNQ==} cpu: [arm64] os: [win32] - turbo@2.8.7: - resolution: {integrity: sha512-RBLh5caMAu1kFdTK1jgH2gH/z+jFsvX5rGbhgJ9nlIAWXSvxlzwId05uDlBA1+pBd3wO/UaKYzaQZQBXDd7kcA==} + turbo@2.8.8: + resolution: {integrity: sha512-wP+axjWAKzYfZ7bghuggVST9bX4j5cn3NPMU9NPQqtwaUKL9n9JOnWWBIc9gdrEma1aViYR73EoPjDEpVq+liQ==} hasBin: true type-check@0.4.0: @@ -9628,8 +9628,8 @@ packages: peerDependencies: typedoc: 0.28.x - typedoc@0.28.16: - resolution: {integrity: sha512-x4xW77QC3i5DUFMBp0qjukOTnr/sSg+oEs86nB3LjDslvAmwe/PUGDWbe3GrIqt59oTqoXK5GRK9tAa0sYMiog==} + typedoc@0.28.17: + resolution: {integrity: sha512-ZkJ2G7mZrbxrKxinTQMjFqsCoYY6a5Luwv2GKbTnBCEgV2ihYm5CflA9JnJAwH0pZWavqfYxmDkFHPt4yx2oDQ==} engines: {node: '>= 18', pnpm: '>= 10'} hasBin: true peerDependencies: @@ -9654,11 +9654,11 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - unconfig-core@7.4.2: - resolution: {integrity: sha512-VgPCvLWugINbXvMQDf8Jh0mlbvNjNC6eSUziHsBCMpxR05OPrNrvDnyatdMjRgcHaaNsCqz+wjNXxNw1kRLHUg==} + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} - unconfig@7.4.2: - resolution: {integrity: sha512-nrMlWRQ1xdTjSnSUqvYqJzbTBFugoqHobQj58B2bc8qxHKBBHMNNsWQFP3Cd3/JZK907voM2geYPWqD4VK3MPQ==} + unconfig@7.5.0: + resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -14087,31 +14087,31 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/package@2.5.7(svelte@5.50.3)(typescript@5.9.3)': + '@sveltejs/package@2.5.7(svelte@5.51.0)(typescript@5.9.3)': dependencies: chokidar: 5.0.0 kleur: 4.1.5 sade: 1.8.1 semver: 7.7.4 - svelte: 5.50.3 - svelte2tsx: 0.7.47(svelte@5.50.3)(typescript@5.9.3) + svelte: 5.51.0 + svelte2tsx: 0.7.48(svelte@5.51.0)(typescript@5.9.3) transitivePeerDependencies: - typescript - '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)))(svelte@5.50.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)))(svelte@5.51.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.51.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)) obug: 2.1.1 - svelte: 5.50.3 + svelte: 5.51.0 vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2) - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)))(svelte@5.50.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)))(svelte@5.51.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 - svelte: 5.50.3 + svelte: 5.51.0 vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2) vitefu: 1.1.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)) @@ -14602,7 +14602,7 @@ snapshots: consola: 3.4.2 sharp: 0.33.5 sharp-ico: 0.1.5 - unconfig: 7.4.2 + unconfig: 7.5.0 '@vitejs/plugin-basic-ssl@2.1.0(vite@7.3.0(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2))': dependencies: @@ -15941,7 +15941,7 @@ snapshots: dotenv-expand@11.0.7: dependencies: - dotenv: 16.6.1 + dotenv: 16.4.7 dotenv@16.4.7: {} @@ -20803,31 +20803,32 @@ snapshots: has-property-descriptors: 1.0.2 set-function-name: 2.0.2 - svelte-check@4.3.6(picomatch@4.0.3)(svelte@5.50.3)(typescript@5.9.3): + svelte-check@4.4.0(picomatch@4.0.3)(svelte@5.51.0)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.3) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.50.3 + svelte: 5.51.0 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte2tsx@0.7.47(svelte@5.50.3)(typescript@5.9.3): + svelte2tsx@0.7.48(svelte@5.51.0)(typescript@5.9.3): dependencies: dedent-js: 1.0.1 scule: 1.3.0 - svelte: 5.50.3 + svelte: 5.51.0 typescript: 5.9.3 - svelte@5.50.3: + svelte@5.51.0: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 '@sveltejs/acorn-typescript': 1.0.9(acorn@8.15.0) '@types/estree': 1.0.8 + '@types/trusted-types': 2.0.7 acorn: 8.15.0 aria-query: 5.3.2 axobject-query: 4.1.0 @@ -21005,32 +21006,32 @@ snapshots: dependencies: safe-buffer: 5.2.1 - turbo-darwin-64@2.8.7: + turbo-darwin-64@2.8.8: optional: true - turbo-darwin-arm64@2.8.7: + turbo-darwin-arm64@2.8.8: optional: true - turbo-linux-64@2.8.7: + turbo-linux-64@2.8.8: optional: true - turbo-linux-arm64@2.8.7: + turbo-linux-arm64@2.8.8: optional: true - turbo-windows-64@2.8.7: + turbo-windows-64@2.8.8: optional: true - turbo-windows-arm64@2.8.7: + turbo-windows-arm64@2.8.8: optional: true - turbo@2.8.7: + turbo@2.8.8: optionalDependencies: - turbo-darwin-64: 2.8.7 - turbo-darwin-arm64: 2.8.7 - turbo-linux-64: 2.8.7 - turbo-linux-arm64: 2.8.7 - turbo-windows-64: 2.8.7 - turbo-windows-arm64: 2.8.7 + turbo-darwin-64: 2.8.8 + turbo-darwin-arm64: 2.8.8 + turbo-linux-64: 2.8.8 + turbo-linux-arm64: 2.8.8 + turbo-windows-64: 2.8.8 + turbo-windows-arm64: 2.8.8 type-check@0.4.0: dependencies: @@ -21084,11 +21085,11 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typedoc-plugin-markdown@4.10.0(typedoc@0.28.16(typescript@5.9.3)): + typedoc-plugin-markdown@4.10.0(typedoc@0.28.17(typescript@5.9.3)): dependencies: - typedoc: 0.28.16(typescript@5.9.3) + typedoc: 0.28.17(typescript@5.9.3) - typedoc@0.28.16(typescript@5.9.3): + typedoc@0.28.17(typescript@5.9.3): dependencies: '@gerrit0/mini-shiki': 3.22.0 lunr: 2.3.9 @@ -21119,18 +21120,18 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - unconfig-core@7.4.2: + unconfig-core@7.5.0: dependencies: '@quansync/fs': 1.0.0 quansync: 1.0.0 - unconfig@7.4.2: + unconfig@7.5.0: dependencies: '@quansync/fs': 1.0.0 defu: 6.1.4 jiti: 2.6.1 quansync: 1.0.0 - unconfig-core: 7.4.2 + unconfig-core: 7.5.0 undici-types@6.21.0: {} From e62d12a04df79093ac6b15db35030f40d37917aa Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 14:21:17 +0100 Subject: [PATCH 04/60] Add microtask batch utility Added a reusable microtask batching helper for coalescing queued items per tick. Added focused tests for coalescing, flushNow behavior, and reentrant pushes. Exported the new utility from common index. --- packages/common/src/Microtask.ts | 49 +++++++++++++++ packages/common/src/index.ts | 1 + packages/common/test/Microtask.test.ts | 86 ++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 packages/common/src/Microtask.ts create mode 100644 packages/common/test/Microtask.test.ts diff --git a/packages/common/src/Microtask.ts b/packages/common/src/Microtask.ts new file mode 100644 index 000000000..389350202 --- /dev/null +++ b/packages/common/src/Microtask.ts @@ -0,0 +1,49 @@ +/** + * Microtask queue utilities. + * + * @module + */ + +import { appendToArray, type NonEmptyReadonlyArray } from "./Array.js"; + +/** + * Batches values and flushes them in a single microtask. + * + * Calls to {@link MicrotaskBatch.push} within the same tick are coalesced into + * one flush. + */ +export interface MicrotaskBatch { + /** Enqueues one item and schedules a microtask flush if needed. */ + readonly push: (item: T) => void; + + /** Flushes queued items immediately. */ + readonly flushNow: () => void; +} + +/** Creates {@link MicrotaskBatch}. */ +export const createMicrotaskBatch = ( + onFlush: (items: NonEmptyReadonlyArray) => void, +): MicrotaskBatch => { + let queue: NonEmptyReadonlyArray | null = null; + + const flushNow = () => { + if (queue == null) return; + const queuedItems = queue; + queue = null; + onFlush(queuedItems); + }; + + return { + push: (item) => { + if (queue == null) { + queue = [item]; + queueMicrotask(flushNow); + return; + } + + queue = appendToArray(queue, item); + }, + + flushNow, + }; +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 295070cef..71606fa0e 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -19,6 +19,7 @@ export * from "./Function.js"; export * from "./Identicon.js"; export * from "./Instances.js"; export * from "./Listeners.js"; +export * from "./Microtask.js"; export * from "./Number.js"; export * from "./Object.js"; export * from "./Option.js"; diff --git a/packages/common/test/Microtask.test.ts b/packages/common/test/Microtask.test.ts new file mode 100644 index 000000000..76c56c507 --- /dev/null +++ b/packages/common/test/Microtask.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "vitest"; +import { createMicrotaskBatch } from "../src/Microtask.js"; + +describe("createMicrotaskBatch", () => { + test("coalesces multiple pushes in one tick into one flush", async () => { + const flushed: Array> = []; + const batch = createMicrotaskBatch((items) => { + flushed.push(items); + }); + + batch.push(1); + batch.push(2); + + expect(flushed).toEqual([]); + + await Promise.resolve(); + + expect(flushed).toEqual([[1, 2]]); + }); + + test("flushNow flushes immediately and pending microtask becomes no-op", async () => { + const flushed: Array> = []; + const batch = createMicrotaskBatch((items) => { + flushed.push(items); + }); + + batch.push(1); + batch.flushNow(); + + expect(flushed).toEqual([[1]]); + + await Promise.resolve(); + + expect(flushed).toEqual([[1]]); + + batch.push(2); + await Promise.resolve(); + + expect(flushed).toEqual([[1], [2]]); + }); + + test("flushNow on empty queue does nothing", () => { + const flushed: Array> = []; + const batch = createMicrotaskBatch((items) => { + flushed.push(items); + }); + + batch.flushNow(); + + expect(flushed).toEqual([]); + }); + + test("passes snapshot so previous flushed arrays stay unchanged", async () => { + const flushed: Array> = []; + const batch = createMicrotaskBatch((items) => { + flushed.push(items); + }); + + batch.push(1); + batch.push(2); + await Promise.resolve(); + + batch.push(3); + await Promise.resolve(); + + expect(flushed[0]).toEqual([1, 2]); + expect(flushed[1]).toEqual([3]); + }); + + test("reentrant push during onFlush is processed in next microtask", async () => { + const flushed: Array> = []; + const batch = createMicrotaskBatch((items) => { + flushed.push(items); + if (items[0] === 1) { + batch.push(2); + } + }); + + batch.push(1); + + await Promise.resolve(); + await Promise.resolve(); + + expect(flushed).toEqual([[1], [2]]); + }); +}); From 5d7cf73d0d37c720b255e8799c4a16d69e83da0f Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 14:22:04 +0100 Subject: [PATCH 05/60] Add ref count utility Added a reusable reference counting helper for arbitrary keys. Added focused tests for increment/decrement behavior, key snapshots, clear, and object identity semantics. Exported the new utility from common index. --- packages/common/src/RefCount.ts | 79 +++++++++++++++++ packages/common/src/index.ts | 1 + packages/common/test/RefCount.test.ts | 121 ++++++++++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 packages/common/src/RefCount.ts create mode 100644 packages/common/test/RefCount.test.ts diff --git a/packages/common/src/RefCount.ts b/packages/common/src/RefCount.ts new file mode 100644 index 000000000..61e7a7225 --- /dev/null +++ b/packages/common/src/RefCount.ts @@ -0,0 +1,79 @@ +/** + * Reference counting for arbitrary keys. + * + * @module + */ + +import type { Option } from "./Option.js"; +import { none, some } from "./Option.js"; +import { NonNegativeInt, PositiveInt } from "./Type.js"; + +/** + * Tracks usage counts for keys. + * + * Counts are always positive for tracked keys. A key is removed when its count + * reaches zero. + * + * Keys use reference identity, the same as `Map` keys. + */ +export interface RefCount { + /** Increments key count and returns the new count. */ + readonly increment: (key: TKey) => PositiveInt; + + /** + * Decrements key count and returns the new count. + * + * Returns {@link none} when the key is not tracked. + */ + readonly decrement: (key: TKey) => Option; + + /** Gets current count for key. Returns `0` when the key is not tracked. */ + readonly getCount: (key: TKey) => NonNegativeInt; + + /** Returns `true` when the key is tracked with count greater than zero. */ + readonly has: (key: TKey) => boolean; + + /** Returns all currently tracked keys. */ + readonly keys: () => ReadonlySet; + + /** Clears all tracked keys and counts. */ + readonly clear: () => void; +} + +/** Creates {@link RefCount}. */ +export const createRefCount = (): RefCount => { + const counts = new Map(); + const zero = NonNegativeInt.orThrow(0); + + return { + increment: (key) => { + const nextCount = PositiveInt.orThrow((counts.get(key) ?? 0) + 1); + counts.set(key, nextCount); + return nextCount; + }, + + decrement: (key) => { + const count = counts.get(key); + if (count == null) return none; + + if (count === 1) { + counts.delete(key); + return some(zero); + } + + const nextCount = PositiveInt.orThrow(count - 1); + counts.set(key, nextCount); + return some(nextCount); + }, + + getCount: (key) => counts.get(key) ?? zero, + + has: (key) => counts.has(key), + + keys: () => new Set(counts.keys()), + + clear: () => { + counts.clear(); + }, + }; +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 71606fa0e..90cf747d4 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -29,6 +29,7 @@ export * from "./Polyfills.js"; export * from "./Random.js"; export * from "./Redacted.js"; export * from "./Ref.js"; +export * from "./RefCount.js"; export * from "./Relation.js"; export * from "./Resources.js"; export * from "./Result.js"; diff --git a/packages/common/test/RefCount.test.ts b/packages/common/test/RefCount.test.ts new file mode 100644 index 000000000..fbc7c6c39 --- /dev/null +++ b/packages/common/test/RefCount.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, expectTypeOf, test } from "vitest"; +import { isNone, isSome, type Option } from "../src/Option.js"; +import { createRefCount } from "../src/RefCount.js"; +import type { NonNegativeInt } from "../src/Type.js"; + +describe("createRefCount", () => { + test("increment adds a new key with count 1", () => { + const refCount = createRefCount(); + + const count = refCount.increment("a"); + + expect(count).toBe(1); + expect(refCount.getCount("a")).toBe(1); + expect(refCount.has("a")).toBe(true); + }); + + test("increment increases count for existing key", () => { + const refCount = createRefCount(); + + refCount.increment("a"); + const count = refCount.increment("a"); + + expect(count).toBe(2); + expect(refCount.getCount("a")).toBe(2); + }); + + test("decrement returns none for missing key", () => { + const refCount = createRefCount(); + + const result = refCount.decrement("missing"); + + expect(isNone(result)).toBe(true); + expect(refCount.getCount("missing")).toBe(0); + expect(refCount.has("missing")).toBe(false); + }); + + test("decrement removes key when count reaches zero", () => { + const refCount = createRefCount(); + refCount.increment("a"); + + const result = refCount.decrement("a"); + + expect(isSome(result)).toBe(true); + if (isSome(result)) { + expect(result.value).toBe(0); + } + expect(refCount.getCount("a")).toBe(0); + expect(refCount.has("a")).toBe(false); + }); + + test("decrement decreases count and keeps key when count stays positive", () => { + const refCount = createRefCount(); + refCount.increment("a"); + refCount.increment("a"); + + const result = refCount.decrement("a"); + + expect(isSome(result)).toBe(true); + if (isSome(result)) { + expect(result.value).toBe(1); + } + expect(refCount.getCount("a")).toBe(1); + expect(refCount.has("a")).toBe(true); + }); + + test("keys returns tracked keys", () => { + const refCount = createRefCount(); + + refCount.increment("a"); + refCount.increment("a"); + refCount.increment("b"); + + expect(refCount.keys()).toEqual(new Set(["a", "b"])); + }); + + test("keys returns snapshot set", () => { + const refCount = createRefCount(); + refCount.increment("a"); + + const keys = refCount.keys() as Set; + keys.add("b"); + + expect(refCount.keys()).toEqual(new Set(["a"])); + expect(refCount.has("b")).toBe(false); + }); + + test("clear removes all keys and counts", () => { + const refCount = createRefCount(); + + refCount.increment("a"); + refCount.increment("b"); + refCount.clear(); + + expect(refCount.keys()).toEqual(new Set()); + expect(refCount.getCount("a")).toBe(0); + expect(refCount.getCount("b")).toBe(0); + expect(refCount.has("a")).toBe(false); + expect(refCount.has("b")).toBe(false); + }); + + test("uses reference identity for object keys", () => { + const refCount = createRefCount<{ readonly id: string }>(); + + const keyA = { id: "same" }; + const keyB = { id: "same" }; + + refCount.increment(keyA); + refCount.increment(keyB); + + expect(refCount.getCount(keyA)).toBe(1); + expect(refCount.getCount(keyB)).toBe(1); + expect(refCount.keys().size).toBe(2); + }); + + test("decrement has Option NonNegativeInt return type", () => { + const refCount = createRefCount(); + const result = refCount.decrement("a"); + + expectTypeOf(result).toEqualTypeOf>(); + }); +}); From 31e07ebddedde2de655f8a82643ad4f3ffaa37ba Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 14:24:41 +0100 Subject: [PATCH 06/60] Implement mutation methods Added Evolu-to-worker mutation input contract and worker-side message handling scaffold. Implemented mutation dispatch flow in createEvolu with microtask batching and serialized mutate payloads. Expanded local-first mutation tests for insert/update/upsert dispatch, coalescing, ownerId, and onComplete callback IDs. Updated React web createEvoluDeps doc wording to match shared dependency semantics. --- packages/common/src/local-first/Evolu.ts | 138 +++++++++++---- packages/common/src/local-first/Worker.ts | 27 ++- .../common/test/local-first/Evolu.test.ts | 158 +++++++++++++++++- packages/react-web/src/local-first/Evolu.ts | 2 +- 4 files changed, 291 insertions(+), 34 deletions(-) diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index 1baa46a0a..7d58fbeb8 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -5,17 +5,28 @@ */ import type { ConsoleDep } from "../Console.js"; +import { createCallbacks } from "../Callbacks.js"; import { createConsole } from "../Console.js"; import { createUnknownError } from "../Error.js"; -import { exhaustiveCheck } from "../Function.js"; +import { assert } from "../Assert.js"; +import { exhaustiveCheck, todo } from "../Function.js"; import type { Listener, Unsubscribe } from "../Listeners.js"; +import { createMicrotaskBatch } from "../Microtask.js"; import type { FlushSyncDep, ReloadAppDep } from "../Platform.js"; +import { createRefCount } from "../RefCount.js"; import { err, ok } from "../Result.js"; +import { SqliteBoolean, sqliteBooleanToBoolean } from "../Sqlite.js"; import type { ReadonlyStore } from "../Store.js"; import { createStore } from "../Store.js"; import type { Task } from "../Task.js"; import type { Id, TypeError } from "../Type.js"; -import { brand, createIdFromString, Name, UrlSafeString } from "../Type.js"; +import { + brand, + createId, + createIdFromString, + Name, + UrlSafeString, +} from "../Type.js"; import type { CreateMessageChannelDep } from "../Worker.js"; import type { EvoluError } from "./Error.js"; import type { AppOwner, OwnerTransport } from "./Owner.js"; @@ -30,17 +41,21 @@ import type { QueriesToQueryRowsPromises, Query, QueryRows, + QueryRowsMap, Row, } from "./Query.js"; import type { EvoluSchema, IndexesConfig, Mutation, + MutationChange, ValidateSchema, } from "./Schema.js"; +import { DbChange } from "./Storage.js"; import type { SyncOwner } from "./Sync.js"; import type { Timestamp } from "./Timestamp.js"; -import type { EvoluTabOutput, EvoluWorkerDep } from "./Worker.js"; +import type { EvoluInput, EvoluTabOutput, EvoluWorkerDep } from "./Worker.js"; +import { mapArray } from "../Array.js"; export interface EvoluConfig { /** @@ -305,7 +320,7 @@ export interface Evolu< * * @see {@link Mutation} */ - insert: Mutation; + readonly insert: Mutation; /** * Updates a row and returns the {@link Id}. @@ -326,7 +341,7 @@ export interface Evolu< * * @see {@link Mutation} */ - update: Mutation; + readonly update: Mutation; /** * Upserts a row and returns the {@link Id}. @@ -352,7 +367,7 @@ export interface Evolu< * * @see {@link Mutation} */ - upsert: Mutation; + readonly upsert: Mutation; /** * // TODO: Ten naming je furt divnej, syncOwner? subscribeOwner? // hmm, use @@ -399,36 +414,39 @@ export type EvoluPlatformDeps = CreateMessageChannelDep & Partial; /** - * Creates Evolu dependencies from platform-specific dependencies. Returns a - * disposable deps object that disposes all used resources. + * Creates shared dependencies used by all {@link createEvolu} instances on a + * platform. + * + * Call this once per platform and reuse the returned deps when creating + * multiple Evolu instances. The returned deps object owns long-lived resources + * such as worker channels and the shared {@link EvoluErrorDep.evoluError} + * store. + * + * Dispose it only during app shutdown. */ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => { const { createMessageChannel, evoluWorker } = deps; const console = deps.console ?? createConsole(); - const evoluError = createStore(null); const stack = new DisposableStack(); stack.use(evoluWorker); + const evoluError = stack.use(createStore(null)); const tabChannel = stack.use(createMessageChannel()); tabChannel.port2.onMessage = (output) => { switch (output.type) { - case "ConsoleEntry": { + case "ConsoleEntry": console.write(output.entry); - // Fallback channel for unexpected errors without EvoluError typing. if (output.entry.method === "error") { evoluError.set(createUnknownError(output.entry.args)); } break; - } - case "EvoluError": { + case "EvoluError": evoluError.set(output.error); - // Keep typed errors visible in logs as operational failures. console.error(output.error); break; - } default: exhaustiveCheck(output); } @@ -439,12 +457,12 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => { [tabChannel.port1.native], ); - const movedStack = stack.move(); + const moved = stack.move(); return { ...deps, evoluError, - [Symbol.dispose]: movedStack[Symbol.dispose].bind(movedStack), + [Symbol.dispose]: () => moved.dispose(), }; }; @@ -459,31 +477,95 @@ export const createEvolu = ): Task, never, EvoluPlatformDeps> => async (run) => { const { createMessageChannel, evoluWorker } = run.deps; + const { appName, appOwner = createAppOwner(createOwnerSecret(run.deps)) } = config; const name = Name.orThrow(`${appName}-${createIdFromString(appOwner.id)}`); const console = run.deps.console.child(name).child("Evolu"); - console.info("createEvolu", { config }); - await using stack = run.stack(); + const _rowsStore = createStore(new Map()); + const subscribedQueriesRefCount = createRefCount(); + const onCompleteCallbacks = createCallbacks(run.deps); - // Set up the evolu channel for bidirectional communication. - // TODO: Define EvoluRequest/EvoluResponse types. - const evoluChannel = stack.use(createMessageChannel()); - evoluWorker.port.postMessage( - { type: "InitEvolu", port: evoluChannel.port1.native }, - [evoluChannel.port1.native], - ); + await using stack = run.stack(); - // console + const { + port1: { postMessage }, + port2, + } = stack.use(createMessageChannel()); + + evoluWorker.port.postMessage({ type: "InitEvolu", port: port2.native }, [ + port2.native, + ]); + + const mutateBatch = createMicrotaskBatch<{ + readonly change: MutationChange; + readonly onComplete: (() => void) | undefined; + }>((items) => { + postMessage({ + type: "mutate", + changes: mapArray(items, (item) => item.change), + onCompleteIds: items.flatMap((item) => + item.onComplete + ? [onCompleteCallbacks.register(item.onComplete)] + : [], + ), + subscribedQueries: [...subscribedQueriesRefCount.keys()], + }); + }); + + const createMutation = + ( + kind: Kind, + ): Mutation => + (table, values, options) => { + const { + id = createId(run.deps), + isDeleted, + ...changeValues + } = values as { id?: Id; isDeleted?: unknown; [key: string]: unknown }; + + const dbChange = { + table, + id, + values: changeValues, + isInsert: kind === "insert" || kind === "upsert", + isDelete: SqliteBoolean.is(isDeleted) + ? sqliteBooleanToBoolean(isDeleted) + : null, + }; + + assert( + DbChange.is(dbChange), + `Invalid DbChange for table '${String(table)}'.`, + ); + + mutateBatch.push({ + change: { ...dbChange, ownerId: options?.ownerId }, + onComplete: options?.onComplete, + }); + + return { id }; + }; const moved = stack.move(); + return ok({ name, appOwner, + + loadQuery: todo, + loadQueries: todo, + subscribeQuery: todo, + getQueryRows: todo, + insert: createMutation("insert"), + update: createMutation("update"), + upsert: createMutation("upsert"), + useOwner: todo, + [Symbol.asyncDispose]: () => moved.disposeAsync(), - } as unknown as Evolu); + } as Evolu); }; // export interface ErrorStoreDep { diff --git a/packages/common/src/local-first/Worker.ts b/packages/common/src/local-first/Worker.ts index 5e9c36ad8..ea3f53cbb 100644 --- a/packages/common/src/local-first/Worker.ts +++ b/packages/common/src/local-first/Worker.ts @@ -8,6 +8,8 @@ import type { ConsoleEntry, ConsoleStoreOutputEntryDep } from "../Console.js"; import { exhaustiveCheck } from "../Function.js"; import { ok } from "../Result.js"; import type { Task } from "../Task.js"; +import type { NonEmptyReadonlyArray } from "../Array.js"; +import type { CallbackId } from "../Callbacks.js"; import type { CreateMessagePortDep, MessagePort, @@ -16,6 +18,8 @@ import type { SharedWorkerSelf, } from "../Worker.js"; import type { EvoluError } from "./Error.js"; +import type { Query } from "./Query.js"; +import type { MutationChange } from "./Schema.js"; export type EvoluWorker = SharedWorker; @@ -23,12 +27,27 @@ export interface EvoluWorkerDep { readonly evoluWorker: EvoluWorker; } +/** + * Messages sent from an Evolu instance to the worker-side Evolu port. + * + * Redesign status: currently only mutation dispatch is defined. Additional + * request variants will be added as query and owner flows are implemented. + */ +export interface EvoluInput { + readonly type: "mutate"; + readonly changes: NonEmptyReadonlyArray; + readonly onCompleteIds: ReadonlyArray; + readonly subscribedQueries: ReadonlyArray; +} + export type EvoluWorkerInput = | { + /** Tab-level channel for broadcast outputs (console/error). */ readonly type: "InitTab"; readonly port: NativeMessagePort; } | { + /** Per-Evolu instance request channel. */ readonly type: "InitEvolu"; readonly port: NativeMessagePort; }; @@ -99,8 +118,12 @@ export const initEvoluWorker = // TODO: Wrap port, do async init (open DB, load owner), // then set onMessage to start processing requests. // Messages are queued until onMessage is assigned. - const _evoluPort = createMessagePort(message.port); - // _evoluPort.onMessage() + const evoluPort = createMessagePort( + message.port, + ); + evoluPort.onMessage = (message) => { + console.log(message); + }; break; } default: diff --git a/packages/common/test/local-first/Evolu.test.ts b/packages/common/test/local-first/Evolu.test.ts index 74f363e56..12b914731 100644 --- a/packages/common/test/local-first/Evolu.test.ts +++ b/packages/common/test/local-first/Evolu.test.ts @@ -9,6 +9,7 @@ import { createEvoluDeps, } from "../../src/local-first/Evolu.js"; import type { + EvoluInput, EvoluTabOutput, EvoluWorker, EvoluWorkerInput, @@ -41,14 +42,38 @@ const Schema = { }, }; -const createEvoluRun = () => { - const { worker } = testCreateSharedWorker(); - return testCreateRun({ +const createEvoluRun = ( + worker: EvoluWorker = testCreateSharedWorker().worker, +) => + testCreateRun({ console: testCreateConsole(), createMessageChannel: testCreateMessageChannel, reloadApp: lazyVoid, evoluWorker: worker, }); + +const setupCreateEvolu = async () => { + const { worker, self, connect } = testCreateSharedWorker(); + const evoluInputs: Array = []; + + self.onConnect = (port) => { + port.onMessage = (message) => { + if (message.type !== "InitEvolu") return; + const evoluPort = testCreateMessagePort(message.port); + evoluPort.onMessage = (input) => { + evoluInputs.push(input); + }; + }; + }; + connect(); + + const run = createEvoluRun(worker); + + const result = await run( + createEvolu(Schema, { appName: testAppName, appOwner: testAppOwner }), + ); + + return { run, result, evoluInputs }; }; test("AppName", () => { @@ -298,6 +323,133 @@ describe("createEvolu", () => { }); }); +describe("mutations", () => { + test("insert posts mutate with generated id and stripped values", async () => { + const { run, result, evoluInputs } = await setupCreateEvolu(); + await using _run = run; + + if (!result.ok) return; + + const { id } = result.value.insert("todo", { + title: NonEmptyString100.orThrow("Todo 1"), + }); + + await Promise.resolve(); + + expect(evoluInputs).toHaveLength(1); + expect(evoluInputs[0]).toEqual({ + type: "mutate", + changes: [ + { + table: "todo", + id, + values: { title: "Todo 1" }, + isInsert: true, + isDelete: null, + ownerId: undefined, + }, + ], + onCompleteIds: [], + subscribedQueries: [], + }); + }); + + test("update and upsert preserve passed id and set isInsert correctly", async () => { + const { run, result, evoluInputs } = await setupCreateEvolu(); + await using _run = run; + + if (!result.ok) return; + + const updateId = TodoId.orThrow(createIdFromString("todo-update")); + const upsertId = TodoId.orThrow(createIdFromString("todo-upsert")); + + result.value.update("todo", { + id: updateId, + title: NonEmptyString100.orThrow("Updated"), + isDeleted: 1, + }); + + result.value.upsert("todo", { + id: upsertId, + title: NonEmptyString100.orThrow("Upserted"), + }); + + await Promise.resolve(); + + expect(evoluInputs).toHaveLength(1); + expect(evoluInputs[0]).toEqual({ + type: "mutate", + changes: [ + { + table: "todo", + id: updateId, + values: { title: "Updated" }, + isInsert: false, + isDelete: true, + ownerId: undefined, + }, + { + table: "todo", + id: upsertId, + values: { title: "Upserted" }, + isInsert: true, + isDelete: null, + ownerId: undefined, + }, + ], + onCompleteIds: [], + subscribedQueries: [], + }); + }); + + test("coalesces insert, update, and upsert in one microtask", async () => { + const { run, result, evoluInputs } = await setupCreateEvolu(); + await using _run = run; + + if (!result.ok) return; + + const updateId = TodoId.orThrow(createIdFromString("todo-batch-update")); + const upsertId = TodoId.orThrow(createIdFromString("todo-batch-upsert")); + + result.value.insert("todo", { title: NonEmptyString100.orThrow("A") }); + result.value.update("todo", { + id: updateId, + title: NonEmptyString100.orThrow("B"), + }); + result.value.upsert("todo", { + id: upsertId, + title: NonEmptyString100.orThrow("C"), + }); + + await Promise.resolve(); + + expect(evoluInputs).toHaveLength(1); + expect(evoluInputs[0]?.type).toBe("mutate"); + expect(evoluInputs[0]?.changes).toHaveLength(3); + expect(evoluInputs[0]?.changes[1]?.isInsert).toBe(false); + expect(evoluInputs[0]?.changes[2]?.isInsert).toBe(true); + }); + + test("includes ownerId and onComplete callback ids", async () => { + const { run, result, evoluInputs } = await setupCreateEvolu(); + await using _run = run; + + if (!result.ok) return; + + result.value.insert( + "todo", + { title: NonEmptyString100.orThrow("With callback") }, + { ownerId: testAppOwner.id, onComplete: lazyVoid }, + ); + + await Promise.resolve(); + + expect(evoluInputs).toHaveLength(1); + expect(evoluInputs[0]?.changes[0]?.ownerId).toBe(testAppOwner.id); + expect(evoluInputs[0]?.onCompleteIds).toHaveLength(1); + }); +}); + // import { describe, expectTypeOf, test } from "vitest"; // import { createConsole } from "../../src/Console.js"; // import { constVoid } from "../../src/Function.js"; diff --git a/packages/react-web/src/local-first/Evolu.ts b/packages/react-web/src/local-first/Evolu.ts index 9bd0260fb..4bbd29073 100644 --- a/packages/react-web/src/local-first/Evolu.ts +++ b/packages/react-web/src/local-first/Evolu.ts @@ -3,7 +3,7 @@ import type { EvoluDeps } from "@evolu/common/local-first"; import { createEvoluDeps as createWebEvoluDeps } from "@evolu/web"; import { flushSync } from "react-dom"; -/** Creates Evolu dependencies for web with React DOM flush sync. */ +/** Creates shared Evolu dependencies for React on web with React DOM flush sync. */ export const createEvoluDeps = (deps: Partial = {}): EvoluDeps => ({ ...createWebEvoluDeps(deps), flushSync, From 9d3a49e65f8b3514eabbbc7c921a2a0819a8cc39 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 14:24:59 +0100 Subject: [PATCH 07/60] Add commit batches prompt Added a reusable prompt that guides commit batching workflow with explicit approval gates for batch selection and commit message confirmation. --- .github/prompts/commit-batches.prompt.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/prompts/commit-batches.prompt.md diff --git a/.github/prompts/commit-batches.prompt.md b/.github/prompts/commit-batches.prompt.md new file mode 100644 index 000000000..cf3bde0b3 --- /dev/null +++ b/.github/prompts/commit-batches.prompt.md @@ -0,0 +1,18 @@ +--- +name: commit-batches +description: Suggest commit batches from current changes and process them one by one with approvals. +argument-hint: optional focus for grouping (for example: worker + sync first) +--- + +For each batch: + +1. Check current git changes. +2. Suggest commit batches (group files by coherent intent). +3. Wait for my approval of the proposed batches. +4. For the first approved batch, propose: + - Commit summary (sentence case, no prefix, under 50 characters) + - Commit description + +5. Wait for explicit approval of the message. +6. After approval, commit only files in that batch. +7. Move to the next approved batch and repeat from step 4. From da75834e986484d9d5790949a2e91fa09f6a9071 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 17:08:06 +0100 Subject: [PATCH 08/60] Renamed local-first worker module to Shared and aligned shared worker naming --- packages/common/src/local-first/Evolu.ts | 18 +++++----- .../src/local-first/{Worker.ts => Shared.ts} | 18 +++++----- packages/common/src/local-first/index.ts | 2 +- .../common/test/local-first/Evolu.test.ts | 34 +++++++++---------- .../{Worker.test.ts => Shared.test.ts} | 14 ++++---- packages/react-native/src/shared.ts | 10 +++--- packages/web/src/local-first/Evolu.ts | 6 ++-- packages/web/src/local-first/Worker.worker.ts | 4 +-- 8 files changed, 53 insertions(+), 53 deletions(-) rename packages/common/src/local-first/{Worker.ts => Shared.ts} (90%) rename packages/common/test/local-first/{Worker.test.ts => Shared.test.ts} (96%) diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index 7d58fbeb8..7b8cdadf4 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -54,7 +54,7 @@ import type { import { DbChange } from "./Storage.js"; import type { SyncOwner } from "./Sync.js"; import type { Timestamp } from "./Timestamp.js"; -import type { EvoluInput, EvoluTabOutput, EvoluWorkerDep } from "./Worker.js"; +import type { EvoluInput, EvoluTabOutput, SharedWorkerDep } from "./Shared.js"; import { mapArray } from "../Array.js"; export interface EvoluConfig { @@ -408,7 +408,7 @@ export interface EvoluErrorDep { export type EvoluDeps = EvoluPlatformDeps & EvoluErrorDep & Disposable; export type EvoluPlatformDeps = CreateMessageChannelDep & - EvoluWorkerDep & + SharedWorkerDep & ReloadAppDep & Partial & Partial; @@ -425,11 +425,11 @@ export type EvoluPlatformDeps = CreateMessageChannelDep & * Dispose it only during app shutdown. */ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => { - const { createMessageChannel, evoluWorker } = deps; + const { createMessageChannel, sharedWorker } = deps; const console = deps.console ?? createConsole(); const stack = new DisposableStack(); - stack.use(evoluWorker); + stack.use(sharedWorker); const evoluError = stack.use(createStore(null)); const tabChannel = stack.use(createMessageChannel()); @@ -452,7 +452,7 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => { } }; - evoluWorker.port.postMessage( + sharedWorker.port.postMessage( { type: "InitTab", port: tabChannel.port1.native }, [tabChannel.port1.native], ); @@ -476,7 +476,7 @@ export const createEvolu = config: EvoluConfig, ): Task, never, EvoluPlatformDeps> => async (run) => { - const { createMessageChannel, evoluWorker } = run.deps; + const { createMessageChannel, sharedWorker } = run.deps; const { appName, appOwner = createAppOwner(createOwnerSecret(run.deps)) } = config; @@ -495,7 +495,7 @@ export const createEvolu = port2, } = stack.use(createMessageChannel()); - evoluWorker.port.postMessage({ type: "InitEvolu", port: port2.native }, [ + sharedWorker.port.postMessage({ type: "InitEvolu", port: port2.native }, [ port2.native, ]); @@ -587,7 +587,7 @@ export const createEvolu = // } // const createErrorStore = ( -// deps: CreateMessageChannelDep & EvoluWorkerDep & DisposableStackDep, +// deps: CreateMessageChannelDep & SharedWorkerDep & DisposableStackDep, // ): Store => { // const errorChannel = deps.disposableStack.use( // deps.createMessageChannel(), @@ -596,7 +596,7 @@ export const createEvolu = // createStore(null), // ); -// deps.evoluWorker.port.postMessage( +// deps.sharedWorker.port.postMessage( // { type: "InitErrorStore", port: errorChannel.port1.native }, // [errorChannel.port1.native], // ); diff --git a/packages/common/src/local-first/Worker.ts b/packages/common/src/local-first/Shared.ts similarity index 90% rename from packages/common/src/local-first/Worker.ts rename to packages/common/src/local-first/Shared.ts index ea3f53cbb..d82a8f429 100644 --- a/packages/common/src/local-first/Worker.ts +++ b/packages/common/src/local-first/Shared.ts @@ -14,17 +14,17 @@ import type { CreateMessagePortDep, MessagePort, NativeMessagePort, - SharedWorker, + SharedWorker as CommonSharedWorker, SharedWorkerSelf, } from "../Worker.js"; import type { EvoluError } from "./Error.js"; import type { Query } from "./Query.js"; import type { MutationChange } from "./Schema.js"; -export type EvoluWorker = SharedWorker; +export type SharedWorker = CommonSharedWorker; -export interface EvoluWorkerDep { - readonly evoluWorker: EvoluWorker; +export interface SharedWorkerDep { + readonly sharedWorker: SharedWorker; } /** @@ -40,7 +40,7 @@ export interface EvoluInput { readonly subscribedQueries: ReadonlyArray; } -export type EvoluWorkerInput = +export type SharedWorkerInput = | { /** Tab-level channel for broadcast outputs (console/error). */ readonly type: "InitTab"; @@ -62,9 +62,9 @@ export type EvoluTabOutput = readonly error: EvoluError; }; -export const initEvoluWorker = +export const initSharedWorker = ( - self: SharedWorkerSelf, + self: SharedWorkerSelf, ): Task< AsyncDisposableStack, never, @@ -72,7 +72,7 @@ export const initEvoluWorker = > => async (run) => { const { createMessagePort, consoleStoreOutputEntry } = run.deps; - const console = run.deps.console.child("EvoluWorker"); + const console = run.deps.console.child("SharedWorker"); // TODO: Use heartbeat to detect and prune dead ports. const tabPorts = new Set>(); @@ -96,7 +96,7 @@ export const initEvoluWorker = }), ); - console.info("initEvoluWorker"); + console.info("initSharedWorker"); self.onConnect = (port) => { console.info("onConnect"); diff --git a/packages/common/src/local-first/index.ts b/packages/common/src/local-first/index.ts index 0a3d4c7e9..ed8ac45ff 100644 --- a/packages/common/src/local-first/index.ts +++ b/packages/common/src/local-first/index.ts @@ -6,7 +6,7 @@ export * from "./Protocol.js"; export * from "./Query.js"; export * from "./Relay.js"; export * from "./Schema.js"; -export * from "./Worker.js"; +export * from "./Shared.js"; export * from "./Storage.js"; export * from "./Sync.js"; export * from "./Timestamp.js"; diff --git a/packages/common/test/local-first/Evolu.test.ts b/packages/common/test/local-first/Evolu.test.ts index 12b914731..4726a1ce6 100644 --- a/packages/common/test/local-first/Evolu.test.ts +++ b/packages/common/test/local-first/Evolu.test.ts @@ -11,9 +11,9 @@ import { import type { EvoluInput, EvoluTabOutput, - EvoluWorker, - EvoluWorkerInput, -} from "../../src/local-first/Worker.js"; + SharedWorker, + SharedWorkerInput, +} from "../../src/local-first/Shared.js"; import { err, ok } from "../../src/Result.js"; import { SqliteBoolean } from "../../src/Sqlite.js"; import { testCreateRun } from "../../src/Test.js"; @@ -43,17 +43,17 @@ const Schema = { }; const createEvoluRun = ( - worker: EvoluWorker = testCreateSharedWorker().worker, + worker: SharedWorker = testCreateSharedWorker().worker, ) => testCreateRun({ console: testCreateConsole(), createMessageChannel: testCreateMessageChannel, reloadApp: lazyVoid, - evoluWorker: worker, + sharedWorker: worker, }); const setupCreateEvolu = async () => { - const { worker, self, connect } = testCreateSharedWorker(); + const { worker, self, connect } = testCreateSharedWorker(); const evoluInputs: Array = []; self.onConnect = (port) => { @@ -103,8 +103,8 @@ test("AppName", () => { describe("createEvoluDeps", () => { const setupAndCall = (console?: TestConsole) => { const { worker, self, connect } = - testCreateSharedWorker(); - const messages: Array = []; + testCreateSharedWorker(); + const messages: Array = []; self.onConnect = (port) => { port.onMessage = (message) => messages.push(message); }; @@ -112,7 +112,7 @@ describe("createEvoluDeps", () => { createEvoluDeps({ createMessageChannel: testCreateMessageChannel, - evoluWorker: worker, + sharedWorker: worker, reloadApp: lazyVoid, ...(console && { console }), }); @@ -136,8 +136,8 @@ describe("createEvoluDeps", () => { const setupDepsWithPort = () => { const { worker, self, connect } = - testCreateSharedWorker(); - const messages: Array = []; + testCreateSharedWorker(); + const messages: Array = []; self.onConnect = (port) => { port.onMessage = (message) => messages.push(message); }; @@ -145,12 +145,12 @@ describe("createEvoluDeps", () => { const deps = createEvoluDeps({ createMessageChannel: testCreateMessageChannel, - evoluWorker: worker, + sharedWorker: worker, reloadApp: lazyVoid, }); const initTab = messages[0] as Extract< - EvoluWorkerInput, + SharedWorkerInput, { readonly type: "InitTab" } >; const workerPort = testCreateMessagePort(initTab.port); @@ -163,7 +163,7 @@ describe("createEvoluDeps", () => { const { messages } = setupAndCall(console); const initConsole = messages[0] as Extract< - EvoluWorkerInput, + SharedWorkerInput, { readonly type: "InitTab" } >; const workerPort = testCreateMessagePort(initConsole.port); @@ -242,7 +242,7 @@ describe("createEvoluDeps", () => { test("dispose cleans up resources", () => { const { worker, self, connect } = - testCreateSharedWorker(); + testCreateSharedWorker(); self.onConnect = (port) => { port.onMessage = lazyVoid; }; @@ -250,7 +250,7 @@ describe("createEvoluDeps", () => { const channels: Array<{ readonly isDisposed: () => boolean }> = []; let workerDisposed = false; - const evoluWorker: EvoluWorker = { + const sharedWorker: SharedWorker = { port: worker.port, [Symbol.dispose]: () => { workerDisposed = true; @@ -264,7 +264,7 @@ describe("createEvoluDeps", () => { channels.push(channel); return channel; }, - evoluWorker, + sharedWorker, reloadApp: lazyVoid, }); diff --git a/packages/common/test/local-first/Worker.test.ts b/packages/common/test/local-first/Shared.test.ts similarity index 96% rename from packages/common/test/local-first/Worker.test.ts rename to packages/common/test/local-first/Shared.test.ts index d9db994fc..3cdc32602 100644 --- a/packages/common/test/local-first/Worker.test.ts +++ b/packages/common/test/local-first/Shared.test.ts @@ -3,9 +3,9 @@ import type { ConsoleEntry } from "../../src/Console.js"; import type { ReadonlyStore } from "../../src/Store.js"; import { type EvoluTabOutput, - type EvoluWorkerInput, - initEvoluWorker, -} from "../../src/local-first/Worker.js"; + type SharedWorkerInput, + initSharedWorker, +} from "../../src/local-first/Shared.js"; import { testCreateConsole } from "../../src/Console.js"; import { testCreateRun } from "../../src/Test.js"; import { @@ -15,14 +15,14 @@ import { } from "../../src/Worker.js"; import { describe, expect, test } from "vitest"; -describe("initEvoluWorker", () => { +describe("initSharedWorker", () => { const setupWorker = async ( consoleStoreOutputEntry: ReadonlyStore = createStore( null, ), ) => { const { worker, self, connect } = - testCreateSharedWorker(); + testCreateSharedWorker(); await using run = testCreateRun({ console: testCreateConsole(), @@ -30,9 +30,9 @@ describe("initEvoluWorker", () => { createMessagePort: testCreateMessagePort, }); - const initResult = await run(initEvoluWorker(self)); + const initResult = await run(initSharedWorker(self)); if (!initResult.ok) - throw new Error("initEvoluWorker should always succeed"); + throw new Error("initSharedWorker should always succeed"); connect(); diff --git a/packages/react-native/src/shared.ts b/packages/react-native/src/shared.ts index 05cde6b56..f3909770b 100644 --- a/packages/react-native/src/shared.ts +++ b/packages/react-native/src/shared.ts @@ -8,10 +8,10 @@ import { type ReloadAppDep, type SecureStorage, } from "@evolu/common"; -import type { EvoluDeps, EvoluWorkerInput } from "@evolu/common/local-first"; +import type { EvoluDeps, SharedWorkerInput } from "@evolu/common/local-first"; import { createEvoluDeps as createCommonEvoluDeps, - initEvoluWorker, + initSharedWorker, } from "@evolu/common/local-first"; import { createMessageChannel, @@ -34,15 +34,15 @@ export const createEvoluDeps = ( createMessagePort, }); - const evoluWorker = createSharedWorker((self) => { - workerRun(initEvoluWorker(self)); + const sharedWorker = createSharedWorker((self) => { + workerRun(initSharedWorker(self)); }); return createCommonEvoluDeps({ ...deps, createMessageChannel, reloadApp: deps.reloadApp, - evoluWorker, + sharedWorker, }); }; diff --git a/packages/web/src/local-first/Evolu.ts b/packages/web/src/local-first/Evolu.ts index baa01e33d..0e5249338 100644 --- a/packages/web/src/local-first/Evolu.ts +++ b/packages/web/src/local-first/Evolu.ts @@ -1,5 +1,5 @@ import type { ConsoleDep } from "@evolu/common"; -import type { EvoluDeps, EvoluWorkerInput } from "@evolu/common/local-first"; +import type { EvoluDeps, SharedWorkerInput } from "@evolu/common/local-first"; import { createEvoluDeps as createCommonEvoluDeps } from "@evolu/common/local-first"; import { reloadApp } from "../Platform.js"; import { createMessageChannel, createSharedWorker } from "../Worker.js"; @@ -13,7 +13,7 @@ import { createMessageChannel, createSharedWorker } from "../Worker.js"; /** Creates Evolu dependencies for the web platform. */ export const createEvoluDeps = (deps: Partial = {}): EvoluDeps => { - const evoluWorker = createSharedWorker( + const sharedWorker = createSharedWorker( new SharedWorker(new URL("Worker.worker.js", import.meta.url), { type: "module", }), @@ -23,6 +23,6 @@ export const createEvoluDeps = (deps: Partial = {}): EvoluDeps => { ...deps, createMessageChannel, reloadApp, - evoluWorker, + sharedWorker, }); }; diff --git a/packages/web/src/local-first/Worker.worker.ts b/packages/web/src/local-first/Worker.worker.ts index 69020dd52..bfa7d44e8 100644 --- a/packages/web/src/local-first/Worker.worker.ts +++ b/packages/web/src/local-first/Worker.worker.ts @@ -1,10 +1,10 @@ /// declare const self: SharedWorkerGlobalScope; -import { initEvoluWorker } from "@evolu/common/local-first"; +import { initSharedWorker } from "@evolu/common/local-first"; import { createSharedWorkerSelf, createWorkerRun } from "../Worker.js"; // No disposal (`await using`) is needed — a SharedWorker lives forever. const run = createWorkerRun(); -run(initEvoluWorker(createSharedWorkerSelf(self))); +run(initSharedWorker(createSharedWorkerSelf(self))); From 019ad76a4bcb6d5b4237061c41cfef9325c32a87 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 18:57:18 +0100 Subject: [PATCH 09/60] Add testCreateWorker and worker tests Added testCreateWorker in common worker abstractions and added coverage in the worker test suite for dedicated worker communication, message queueing, and disposal behavior. --- packages/common/src/Worker.ts | 43 +++++++++++++++++++++++++++++ packages/common/test/Worker.test.ts | 39 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/packages/common/src/Worker.ts b/packages/common/src/Worker.ts index 52152f00a..9abe4ec21 100644 --- a/packages/common/src/Worker.ts +++ b/packages/common/src/Worker.ts @@ -182,6 +182,49 @@ export interface SharedWorkerSelf extends Disposable { onConnect: ((port: MessagePort) => void) | null; } +/** + * Creates a connected {@link Worker} / {@link WorkerSelf} pair for testing. + * + * Returns both sides so tests can exercise dedicated worker communication + * without a real worker thread. + */ +export const testCreateWorker = (): { + readonly worker: Worker; + readonly self: WorkerSelf; +} => { + const channel = testCreateMessageChannel(); + + const worker: Worker = { + postMessage: channel.port1.postMessage, + get onMessage() { + return channel.port1.onMessage; + }, + set onMessage(value) { + channel.port1.onMessage = value; + }, + native: channel.port1.native, + [Symbol.dispose]: () => { + channel[Symbol.dispose](); + }, + }; + + const self: WorkerSelf = { + postMessage: channel.port2.postMessage, + get onMessage() { + return channel.port2.onMessage; + }, + set onMessage(value) { + channel.port2.onMessage = value; + }, + native: channel.port2.native, + [Symbol.dispose]: () => { + channel.port2[Symbol.dispose](); + }, + }; + + return { worker, self }; +}; + /** * Creates a connected {@link SharedWorker} / {@link SharedWorkerSelf} pair for * testing. diff --git a/packages/common/test/Worker.test.ts b/packages/common/test/Worker.test.ts index 3f0346cca..76173186c 100644 --- a/packages/common/test/Worker.test.ts +++ b/packages/common/test/Worker.test.ts @@ -5,6 +5,7 @@ import { testCreateMessageChannel, testCreateMessagePort, testCreateSharedWorker, + testCreateWorker, } from "../src/Worker.js"; describe("testCreateMessageChannel", () => { @@ -107,6 +108,44 @@ describe("testCreateMessagePort", () => { }); }); +describe("testCreateWorker", () => { + test("worker and self communicate through ports", () => { + const { worker, self } = testCreateWorker(); + const workerReceived: Array = []; + const selfReceived: Array = []; + + worker.onMessage = (msg) => workerReceived.push(msg); + self.onMessage = (msg) => selfReceived.push(msg); + + worker.postMessage("to-self"); + self.postMessage(123); + + expect(selfReceived).toEqual(["to-self"]); + expect(workerReceived).toEqual([123]); + }); + + test("messages are queued until onMessage is assigned", () => { + const { worker, self } = testCreateWorker(); + worker.postMessage("queued"); + + const received: Array = []; + self.onMessage = (msg) => received.push(msg); + + expect(received).toEqual(["queued"]); + }); + + test("worker dispose clears handlers", () => { + const { worker, self } = testCreateWorker(); + worker.onMessage = lazyVoid; + self.onMessage = lazyVoid; + + worker[Symbol.dispose](); + + expect(worker.onMessage).toBeNull(); + expect(self.onMessage).toBeNull(); + }); +}); + describe("testCreateSharedWorker", () => { test("connect triggers onConnect with worker port", () => { const { self, connect } = testCreateSharedWorker(); From b90fae5dc78c15185fd5574fb2bb03b45ec41781 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 18:58:46 +0100 Subject: [PATCH 10/60] Wire createEvolu to DB worker init Added DB worker dependency wiring to local-first core by introducing CreateDbWorker/DbWorkerInput types, invoking createDbWorker() in createEvolu, and sending { type: "init", name } to the DB worker. Updated local-first tests to provide a test DB worker and assert init messaging. --- packages/common/src/local-first/Db.ts | 17 +++++++ packages/common/src/local-first/Evolu.ts | 19 +++++--- .../common/test/local-first/Evolu.test.ts | 46 +++++++++++++++++++ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index 39c2bc6bb..3ded9960e 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -3,6 +3,23 @@ * * @module */ + +import type { Name } from "../Type.js"; +import type { Worker } from "../Worker.js"; + +export interface DbWorkerInput { + readonly type: "init"; + readonly name: Name; +} + +export type DbWorker = Worker; + +export type CreateDbWorker = () => DbWorker; + +export interface CreateDbWorkerDep { + readonly createDbWorker: CreateDbWorker; +} + // import { // firstInArray, // isNonEmptyArray, diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index 7b8cdadf4..3b3562062 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -4,11 +4,12 @@ * @module */ -import type { ConsoleDep } from "../Console.js"; +import { mapArray } from "../Array.js"; +import { assert } from "../Assert.js"; import { createCallbacks } from "../Callbacks.js"; +import type { ConsoleDep } from "../Console.js"; import { createConsole } from "../Console.js"; import { createUnknownError } from "../Error.js"; -import { assert } from "../Assert.js"; import { exhaustiveCheck, todo } from "../Function.js"; import type { Listener, Unsubscribe } from "../Listeners.js"; import { createMicrotaskBatch } from "../Microtask.js"; @@ -28,6 +29,7 @@ import { UrlSafeString, } from "../Type.js"; import type { CreateMessageChannelDep } from "../Worker.js"; +import type { CreateDbWorkerDep } from "./Db.js"; import type { EvoluError } from "./Error.js"; import type { AppOwner, OwnerTransport } from "./Owner.js"; import { @@ -51,11 +53,10 @@ import type { MutationChange, ValidateSchema, } from "./Schema.js"; +import type { EvoluInput, EvoluTabOutput, SharedWorkerDep } from "./Shared.js"; import { DbChange } from "./Storage.js"; import type { SyncOwner } from "./Sync.js"; import type { Timestamp } from "./Timestamp.js"; -import type { EvoluInput, EvoluTabOutput, SharedWorkerDep } from "./Shared.js"; -import { mapArray } from "../Array.js"; export interface EvoluConfig { /** @@ -407,9 +408,10 @@ export interface EvoluErrorDep { export type EvoluDeps = EvoluPlatformDeps & EvoluErrorDep & Disposable; -export type EvoluPlatformDeps = CreateMessageChannelDep & - SharedWorkerDep & +export type EvoluPlatformDeps = CreateDbWorkerDep & + CreateMessageChannelDep & ReloadAppDep & + SharedWorkerDep & Partial & Partial; @@ -476,7 +478,7 @@ export const createEvolu = config: EvoluConfig, ): Task, never, EvoluPlatformDeps> => async (run) => { - const { createMessageChannel, sharedWorker } = run.deps; + const { createDbWorker, createMessageChannel, sharedWorker } = run.deps; const { appName, appOwner = createAppOwner(createOwnerSecret(run.deps)) } = config; @@ -490,6 +492,9 @@ export const createEvolu = await using stack = run.stack(); + const dbWorker = stack.use(createDbWorker()); + dbWorker.postMessage({ type: "init", name }); + const { port1: { postMessage }, port2, diff --git a/packages/common/test/local-first/Evolu.test.ts b/packages/common/test/local-first/Evolu.test.ts index 4726a1ce6..af724a0a6 100644 --- a/packages/common/test/local-first/Evolu.test.ts +++ b/packages/common/test/local-first/Evolu.test.ts @@ -8,6 +8,11 @@ import { createEvolu, createEvoluDeps, } from "../../src/local-first/Evolu.js"; +import type { + CreateDbWorker, + DbWorker, + DbWorkerInput, +} from "../../src/local-first/Db.js"; import type { EvoluInput, EvoluTabOutput, @@ -27,6 +32,7 @@ import { testCreateMessageChannel, testCreateMessagePort, testCreateSharedWorker, + testCreateWorker, } from "../../src/Worker.js"; import { testAppName } from "../_deps.js"; import { testAppOwner } from "./_fixtures.js"; @@ -42,11 +48,17 @@ const Schema = { }, }; +const testCreateDbWorker: CreateDbWorker = () => { + const { worker } = testCreateWorker(); + return worker as DbWorker; +}; + const createEvoluRun = ( worker: SharedWorker = testCreateSharedWorker().worker, ) => testCreateRun({ console: testCreateConsole(), + createDbWorker: testCreateDbWorker, createMessageChannel: testCreateMessageChannel, reloadApp: lazyVoid, sharedWorker: worker, @@ -111,6 +123,7 @@ describe("createEvoluDeps", () => { connect(); createEvoluDeps({ + createDbWorker: testCreateDbWorker, createMessageChannel: testCreateMessageChannel, sharedWorker: worker, reloadApp: lazyVoid, @@ -144,6 +157,7 @@ describe("createEvoluDeps", () => { connect(); const deps = createEvoluDeps({ + createDbWorker: testCreateDbWorker, createMessageChannel: testCreateMessageChannel, sharedWorker: worker, reloadApp: lazyVoid, @@ -259,6 +273,7 @@ describe("createEvoluDeps", () => { }; const deps = createEvoluDeps({ + createDbWorker: testCreateDbWorker, createMessageChannel: () => { const channel = testCreateMessageChannel(); channels.push(channel); @@ -277,6 +292,37 @@ describe("createEvoluDeps", () => { }); describe("createEvolu", () => { + test("initializes db worker with resolved name", async () => { + const dbWorkerMessages: Array = []; + + const createDbWorker: CreateDbWorker = () => { + const { worker, self } = testCreateWorker(); + self.onMessage = (message) => { + dbWorkerMessages.push(message); + }; + return worker as DbWorker; + }; + + await using run = testCreateRun({ + console: testCreateConsole(), + createDbWorker, + createMessageChannel: testCreateMessageChannel, + reloadApp: lazyVoid, + sharedWorker: testCreateSharedWorker().worker, + }); + + const result = await run( + createEvolu(Schema, { appName: testAppName, appOwner: testAppOwner }), + ); + if (!result.ok) return; + + expect(dbWorkerMessages).toHaveLength(1); + expect(dbWorkerMessages[0]).toEqual({ + type: "init", + name: result.value.name, + }); + }); + test("resolves name from appName and appOwner hash", async () => { await using run = createEvoluRun(); From d4fb5bac289a28f01663d835545715d2d9bdcfa4 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 19:01:11 +0100 Subject: [PATCH 11/60] Wire platform DB workers and worker rename Updated web and React Native platform deps to provide createDbWorker, switched web shared-worker entry path to Shared.worker.js, renamed Worker.worker.ts to Shared.worker.ts, and added Db.worker.ts with TODO notes for DB-worker heartbeat based stale-leader detection and failover via SharedWorker. --- packages/react-native/src/shared.ts | 28 ++++++++++++++++++- packages/web/src/local-first/Db.worker.ts | 18 ++++++++++++ packages/web/src/local-first/Evolu.ts | 23 +++++++++++++-- .../{Worker.worker.ts => Shared.worker.ts} | 0 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 packages/web/src/local-first/Db.worker.ts rename packages/web/src/local-first/{Worker.worker.ts => Shared.worker.ts} (100%) diff --git a/packages/react-native/src/shared.ts b/packages/react-native/src/shared.ts index f3909770b..e355ac3cf 100644 --- a/packages/react-native/src/shared.ts +++ b/packages/react-native/src/shared.ts @@ -8,7 +8,13 @@ import { type ReloadAppDep, type SecureStorage, } from "@evolu/common"; -import type { EvoluDeps, SharedWorkerInput } from "@evolu/common/local-first"; +import type { + CreateDbWorker, + DbWorker, + DbWorkerInput, + EvoluDeps, + SharedWorkerInput, +} from "@evolu/common/local-first"; import { createEvoluDeps as createCommonEvoluDeps, initSharedWorker, @@ -27,6 +33,25 @@ export const createEvoluDeps = ( ): EvoluDeps => { const consoleStoreOutput = createConsoleStoreOutput(); + const createDbWorker: CreateDbWorker = () => { + const channel = createMessageChannel(); + const worker: DbWorker = { + postMessage: channel.port1.postMessage, + get onMessage() { + return channel.port1.onMessage; + }, + set onMessage(value) { + channel.port1.onMessage = value; + }, + native: channel.port1.native, + [Symbol.dispose]: () => { + channel[Symbol.dispose](); + }, + }; + + return worker; + }; + // Worker-side Run lives as long as the app. When RN supports real workers, // this moves to the worker entry point (like web's Worker.worker.ts). const workerRun = createRun({ @@ -40,6 +65,7 @@ export const createEvoluDeps = ( return createCommonEvoluDeps({ ...deps, + createDbWorker, createMessageChannel, reloadApp: deps.reloadApp, sharedWorker, diff --git a/packages/web/src/local-first/Db.worker.ts b/packages/web/src/local-first/Db.worker.ts new file mode 100644 index 000000000..5aa07e5d4 --- /dev/null +++ b/packages/web/src/local-first/Db.worker.ts @@ -0,0 +1,18 @@ +/// +declare const self: DedicatedWorkerGlobalScope; + +import type { DbWorkerInput } from "@evolu/common/local-first"; + +self.onmessage = (event: MessageEvent) => { + const message = event.data; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (message.type === "init") { + // TODO: Add parallel stale-leader detection. + // Heartbeat is emitted by the active DB worker and sent to SharedWorker. + // SharedWorker tracks last-seen heartbeat per Evolu name and if silent for + // 10 seconds, it waits for another DB worker to announce itself alive and + // then routes requests to that worker. + return; + } +}; diff --git a/packages/web/src/local-first/Evolu.ts b/packages/web/src/local-first/Evolu.ts index 0e5249338..fd34a535b 100644 --- a/packages/web/src/local-first/Evolu.ts +++ b/packages/web/src/local-first/Evolu.ts @@ -1,8 +1,17 @@ import type { ConsoleDep } from "@evolu/common"; -import type { EvoluDeps, SharedWorkerInput } from "@evolu/common/local-first"; +import type { + CreateDbWorker, + DbWorkerInput, + EvoluDeps, + SharedWorkerInput, +} from "@evolu/common/local-first"; import { createEvoluDeps as createCommonEvoluDeps } from "@evolu/common/local-first"; import { reloadApp } from "../Platform.js"; -import { createMessageChannel, createSharedWorker } from "../Worker.js"; +import { + createMessageChannel, + createSharedWorker, + createWorker, +} from "../Worker.js"; // // TODO: Redesign. // // eslint-disable-next-line evolu/require-pure-annotation @@ -13,14 +22,22 @@ import { createMessageChannel, createSharedWorker } from "../Worker.js"; /** Creates Evolu dependencies for the web platform. */ export const createEvoluDeps = (deps: Partial = {}): EvoluDeps => { + const createDbWorker: CreateDbWorker = () => + createWorker( + new Worker(new URL("Db.worker.js", import.meta.url), { + type: "module", + }), + ); + const sharedWorker = createSharedWorker( - new SharedWorker(new URL("Worker.worker.js", import.meta.url), { + new SharedWorker(new URL("Shared.worker.js", import.meta.url), { type: "module", }), ); return createCommonEvoluDeps({ ...deps, + createDbWorker, createMessageChannel, reloadApp, sharedWorker, diff --git a/packages/web/src/local-first/Worker.worker.ts b/packages/web/src/local-first/Shared.worker.ts similarity index 100% rename from packages/web/src/local-first/Worker.worker.ts rename to packages/web/src/local-first/Shared.worker.ts From a986d23ecd2f58ae9f4bd92016a54aeb4d596fb5 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 22:53:16 +0100 Subject: [PATCH 12/60] Add leader lock and worker init wiring Added cross-platform LeaderLock abstraction and dependency in common platform types. Introduced shared worker init deps (WorkerInitDep, WorkerDeps) and reused them in worker init tasks. Wired DB worker initialization to acquire leader lock before activation. Implemented web leaderLock and switched web worker entrypoints to createRun(createWorkerDeps()). Added React Native leaderLock, exported it, and wired RN shared deps to run initDbWorker/initSharedWorker with worker-side deps. --- packages/common/src/Platform.ts | 24 +++- packages/common/src/Worker.ts | 8 ++ packages/common/src/local-first/Db.ts | 41 +++++- packages/common/src/local-first/Shared.ts | 16 +-- packages/common/test/_deps.ts | 117 +++++++++++++++++- packages/react-native/src/Platform.ts | 33 +++++ packages/react-native/src/index.ts | 1 + packages/react-native/src/shared.ts | 28 ++--- packages/web/src/Platform.ts | 27 +++- packages/web/src/Worker.ts | 31 +---- packages/web/src/local-first/Db.worker.ts | 23 ++-- packages/web/src/local-first/Shared.worker.ts | 5 +- 12 files changed, 275 insertions(+), 79 deletions(-) create mode 100644 packages/react-native/src/Platform.ts diff --git a/packages/common/src/Platform.ts b/packages/common/src/Platform.ts index 22130c886..38d6ae214 100644 --- a/packages/common/src/Platform.ts +++ b/packages/common/src/Platform.ts @@ -1,9 +1,12 @@ /** - * Runtime platform detection utilities. + * Platform runtime utilities and capability abstractions. * * @module */ +import type { Task } from "./Task.js"; +import type { Name } from "./Type.js"; + /** Returns true if running in React Native with Hermes engine. */ export const isHermes = "HermesInternal" in globalThis; @@ -46,11 +49,26 @@ export interface FlushSyncDep { * Use this after purging persistent storage to clear in-memory state and ensure * the app starts fresh. It does not purge storage itself. * - * - **Web**: Redirects to the specified URL (defaults to `/`) - * - **React Native**: Restarts the app (URL ignored) + * - Web: Redirects to the specified URL (defaults to `/`) + * - React Native: Restarts the app (URL ignored) */ export type ReloadApp = (url?: string) => void; export interface ReloadAppDep { readonly reloadApp: ReloadApp; } + +/** + * Cross-platform leader lock abstraction. + * + * `acquire` blocks until leadership is acquired. + * + * Returns {@link Disposable} lease. Dispose it to release leadership. + */ +export interface LeaderLock { + readonly acquire: (name: Name) => Task; +} + +export interface LeaderLockDep { + readonly leaderLock: LeaderLock; +} diff --git a/packages/common/src/Worker.ts b/packages/common/src/Worker.ts index 9abe4ec21..4c2985f5e 100644 --- a/packages/common/src/Worker.ts +++ b/packages/common/src/Worker.ts @@ -5,6 +5,8 @@ */ import type { Brand } from "./Brand.js"; +import type { ConsoleDep, ConsoleStoreOutputEntryDep } from "./Console.js"; +import type { Task } from "./Task.js"; /** * Platform-agnostic Worker. @@ -96,6 +98,12 @@ export interface CreateMessagePortDep { readonly createMessagePort: CreateMessagePort; } +/** Common dependencies for worker initialization {@link Task}. */ +export type WorkerInitDep = ConsoleStoreOutputEntryDep & CreateMessagePortDep; + +/** Common dependencies for worker entry points. */ +export type WorkerDeps = WorkerInitDep & ConsoleDep; + /** * Platform-agnostic MessageChannel. * diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index 3ded9960e..17c448487 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -4,8 +4,11 @@ * @module */ +import type { LeaderLockDep } from "../Platform.js"; +import { ok } from "../Result.js"; +import type { Task } from "../Task.js"; import type { Name } from "../Type.js"; -import type { Worker } from "../Worker.js"; +import type { Worker, WorkerInitDep, WorkerSelf } from "../Worker.js"; export interface DbWorkerInput { readonly type: "init"; @@ -20,6 +23,42 @@ export interface CreateDbWorkerDep { readonly createDbWorker: CreateDbWorker; } +export const initDbWorker = + ( + self: WorkerSelf, + ): Task => + async (run) => { + const console = run.deps.console.child("DbWorker"); + // TODO: Wire console to shared worker somehow. + + await using stack = run.stack(); + + let initialized = false; + + self.onMessage = (message) => { + if (!initialized) { + initialized = true; + + void run(async (run) => { + const lease = await run(run.deps.leaderLock.acquire(message.name)); + if (!lease.ok) return lease; + + stack.use(lease.value); + return ok(); + }); + } + + console.info("initDbWorker", { name: message.name }); + // TODO: Add parallel stale-leader detection. + // Heartbeat is emitted by the active DB worker and sent to + // SharedWorker. SharedWorker tracks last-seen heartbeat per Evolu + // name and if silent for 10 seconds, it waits for another DB worker + // to announce itself alive and then routes requests to that worker. + }; + + return ok(stack.move()); + }; + // import { // firstInArray, // isNonEmptyArray, diff --git a/packages/common/src/local-first/Shared.ts b/packages/common/src/local-first/Shared.ts index d82a8f429..2663329bb 100644 --- a/packages/common/src/local-first/Shared.ts +++ b/packages/common/src/local-first/Shared.ts @@ -4,18 +4,18 @@ * @module */ -import type { ConsoleEntry, ConsoleStoreOutputEntryDep } from "../Console.js"; +import type { NonEmptyReadonlyArray } from "../Array.js"; +import type { CallbackId } from "../Callbacks.js"; +import type { ConsoleEntry } from "../Console.js"; import { exhaustiveCheck } from "../Function.js"; import { ok } from "../Result.js"; import type { Task } from "../Task.js"; -import type { NonEmptyReadonlyArray } from "../Array.js"; -import type { CallbackId } from "../Callbacks.js"; import type { - CreateMessagePortDep, + SharedWorker as CommonSharedWorker, MessagePort, NativeMessagePort, - SharedWorker as CommonSharedWorker, SharedWorkerSelf, + WorkerInitDep, } from "../Worker.js"; import type { EvoluError } from "./Error.js"; import type { Query } from "./Query.js"; @@ -65,11 +65,7 @@ export type EvoluTabOutput = export const initSharedWorker = ( self: SharedWorkerSelf, - ): Task< - AsyncDisposableStack, - never, - ConsoleStoreOutputEntryDep & CreateMessagePortDep - > => + ): Task => async (run) => { const { createMessagePort, consoleStoreOutputEntry } = run.deps; const console = run.deps.console.child("SharedWorker"); diff --git a/packages/common/test/_deps.ts b/packages/common/test/_deps.ts index 051aee753..caed797ca 100644 --- a/packages/common/test/_deps.ts +++ b/packages/common/test/_deps.ts @@ -1,5 +1,114 @@ -import { Name } from "../src/Type.js"; -import { AppName } from "../src/local-first/Evolu.js"; +import BetterSQLite, { type Statement } from "better-sqlite3"; +import { timingSafeEqual } from "crypto"; +import { assert } from "../src/Assert.js"; +import type { TimingSafeEqual } from "../src/Crypto.js"; +import { lazyTrue, lazyVoid } from "../src/Function.js"; +import { + createRelaySqliteStorage, + createRelayStorageTables, +} from "../src/local-first/Relay.js"; +import { + createBaseSqliteStorageTables, + type StorageConfig, + type StorageDep, +} from "../src/local-first/Storage.js"; +import { ok } from "../src/Result.js"; +import type { + CreateSqliteDriver, + CreateSqliteDriverDep, + SqliteDep, + SqliteDriver, + SqliteRow, +} from "../src/Sqlite.js"; +import { createPreparedStatementsCache, createSqlite } from "../src/Sqlite.js"; +import type { Run } from "../src/Task.js"; +import { testCreateRun, type TestDeps } from "../src/Test.js"; +import { testName } from "./_deps.js"; -export const testName = /*#__PURE__*/ Name.orThrow("Name"); -export const testAppName = /*#__PURE__*/ AppName.orThrow("AppName"); +export const testTimingSafeEqual: TimingSafeEqual = timingSafeEqual; + +export const testCreateRunWithSqlite = async (): Promise< + Run +> => { + const run = testCreateRun({ + createSqliteDriver: testCreateSqliteDriver, + }); + + const sqlite = await run(createSqlite(testName)); + assert(sqlite.ok, "bug"); + + run.defer(() => { + sqlite.value[Symbol.dispose](); + return ok(); + }); + + return run.addDeps({ sqlite: sqlite.value }); +}; + +/** Creates a test Run with relay storage and SQLite deps. */ +export const testCreateRunWithSqliteAndRelayStorage = async ( + config?: Partial, +): Promise> => { + const run = await testCreateRunWithSqlite(); + + createBaseSqliteStorageTables(run.deps); + createRelayStorageTables(run.deps); + + const storage = createRelaySqliteStorage({ + ...run.deps, + timingSafeEqual: testTimingSafeEqual, + })({ + onStorageError: (error) => { + throw new Error(error.type); + }, + isOwnerWithinQuota: lazyTrue, + ...config, + }); + + return run.addDeps({ storage }); +}; + +/** In-memory better-sqlite3 driver for tests. */ +const testCreateSqliteDriver: CreateSqliteDriver = (name) => + createBetterSqliteDriver(name, { mode: "memory" }); + +// Duplicated from @evolu/nodejs because @evolu/common cannot depend on it +// (nodejs depends on common — importing back would create a circular dependency). +const createBetterSqliteDriver: CreateSqliteDriver = (name, options) => () => { + const filename = options?.mode === "memory" ? ":memory:" : `${name}.db`; + const db = new BetterSQLite(filename); + let isDisposed = false; + + const cache = createPreparedStatementsCache( + (sql) => db.prepare(sql), + // Not needed. + // https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md#class-statement + lazyVoid, + ); + + const driver: SqliteDriver = { + exec: (query) => { + // Always prepare is recommended for better-sqlite3 + const prepared = cache.get(query, true); + + if (prepared.reader) { + const rows = prepared.all(query.parameters) as Array; + return { rows, changes: 0 }; + } + + const changes = prepared.run(query.parameters).changes; + return { rows: [], changes }; + }, + + export: () => db.serialize(), + + [Symbol.dispose]: () => { + if (isDisposed) return; + isDisposed = true; + cache[Symbol.dispose](); + db.close(); + }, + }; + + return ok(driver); +}; diff --git a/packages/react-native/src/Platform.ts b/packages/react-native/src/Platform.ts new file mode 100644 index 000000000..b83d95459 --- /dev/null +++ b/packages/react-native/src/Platform.ts @@ -0,0 +1,33 @@ +import { + createInstances, + createMutex, + type LeaderLock, + type Mutex, + type Name, + ok, +} from "@evolu/common"; + +const leaderLockMutexes = createInstances(); + +export const leaderLock: LeaderLock = { + acquire: (name) => async (run) => { + const acquired = Promise.withResolvers(); + const release = Promise.withResolvers(); + + void run.daemon( + leaderLockMutexes.ensure(name, createMutex).withLock(async () => { + acquired.resolve(); + await release.promise; + return ok(); + }), + ); + + await acquired.promise; + + return ok({ + [Symbol.dispose]: () => { + release.resolve(); + }, + }); + }, +}; diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 962d6add3..429fca730 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -1,3 +1,4 @@ export * from "./components/EvoluIdenticon.js"; +export * from "./Platform.js"; export * from "./Task.js"; export * from "./Worker.js"; diff --git a/packages/react-native/src/shared.ts b/packages/react-native/src/shared.ts index e355ac3cf..a73baf1c2 100644 --- a/packages/react-native/src/shared.ts +++ b/packages/react-native/src/shared.ts @@ -17,12 +17,15 @@ import type { } from "@evolu/common/local-first"; import { createEvoluDeps as createCommonEvoluDeps, + initDbWorker, initSharedWorker, } from "@evolu/common/local-first"; +import { leaderLock } from "./Platform.js"; import { createMessageChannel, createMessagePort, createSharedWorker, + createWorker, } from "./Worker.js"; const randomBytes = createRandomBytes(); @@ -33,32 +36,19 @@ export const createEvoluDeps = ( ): EvoluDeps => { const consoleStoreOutput = createConsoleStoreOutput(); - const createDbWorker: CreateDbWorker = () => { - const channel = createMessageChannel(); - const worker: DbWorker = { - postMessage: channel.port1.postMessage, - get onMessage() { - return channel.port1.onMessage; - }, - set onMessage(value) { - channel.port1.onMessage = value; - }, - native: channel.port1.native, - [Symbol.dispose]: () => { - channel[Symbol.dispose](); - }, - }; - - return worker; - }; - // Worker-side Run lives as long as the app. When RN supports real workers, // this moves to the worker entry point (like web's Worker.worker.ts). const workerRun = createRun({ consoleStoreOutputEntry: consoleStoreOutput.entry, createMessagePort, + leaderLock, }); + const createDbWorker: CreateDbWorker = (): DbWorker => + createWorker((self) => { + workerRun(initDbWorker(self)); + }); + const sharedWorker = createSharedWorker((self) => { workerRun(initSharedWorker(self)); }); diff --git a/packages/web/src/Platform.ts b/packages/web/src/Platform.ts index a060b2905..ce40110d9 100644 --- a/packages/web/src/Platform.ts +++ b/packages/web/src/Platform.ts @@ -1,4 +1,29 @@ -import type { ReloadApp } from "@evolu/common"; +import type { LeaderLock, ReloadApp } from "@evolu/common"; +import { ok } from "@evolu/common"; + +export const leaderLock: LeaderLock = { + acquire: (name) => async () => { + const acquired = Promise.withResolvers(); + const release = Promise.withResolvers(); + + void globalThis.navigator.locks.request( + `evolu-leader-${name}`, + { mode: "exclusive" }, + async () => { + acquired.resolve(); + await release.promise; + }, + ); + + await acquired.promise; + + return ok({ + [Symbol.dispose]: () => { + release.resolve(); + }, + }); + }, +}; export const reloadApp: ReloadApp = (url) => { if (typeof document === "undefined") { diff --git a/packages/web/src/Worker.ts b/packages/web/src/Worker.ts index 58ca98d58..c7b11d152 100644 --- a/packages/web/src/Worker.ts +++ b/packages/web/src/Worker.ts @@ -1,16 +1,13 @@ import type { - ConsoleStoreOutputEntryDep, CreateMessagePort, - CreateMessagePortDep, MessageChannel, MessagePort, NativeMessagePort, - Run, - RunDeps, SharedWorker, SharedWorkerSelf, Transferable, Worker, + WorkerDeps, WorkerSelf, } from "@evolu/common"; import { @@ -20,7 +17,6 @@ import { createMultiOutput, createNativeConsoleOutput, } from "@evolu/common"; -import { createRun } from "./Task.js"; /** Creates a {@link Worker} from a Web Worker. */ export const createWorker = ( @@ -109,25 +105,8 @@ export const createSharedWorkerSelf = ( return self; }; -/** - * Creates {@link Run} for a Web Worker or SharedWorker. - * - * Sets up console with {@link createConsoleStoreOutput} combined with native - * console via {@link createMultiOutput}, and provides the store output's entry - * as the `consoleEntry` dependency and {@link createMessagePort} as the - * `createMessagePort` dependency. - * - * ### Example - * - * ```ts - * await using run = createRun(); - * ``` - * - * @group Worker Run - */ -export const createWorkerRun = (): Run< - RunDeps & ConsoleStoreOutputEntryDep & CreateMessagePortDep -> => { +/** Creates deps shared by web worker entry points. */ +export const createWorkerDeps = (): WorkerDeps => { const consoleStoreOutput = createConsoleStoreOutput(); const console = createConsole({ output: createMultiOutput([ @@ -136,11 +115,11 @@ export const createWorkerRun = (): Run< ]), }); - return createRun({ + return { console, consoleStoreOutputEntry: consoleStoreOutput.entry, createMessagePort, - }); + }; }; const wrap = ( diff --git a/packages/web/src/local-first/Db.worker.ts b/packages/web/src/local-first/Db.worker.ts index 5aa07e5d4..1e1da98a0 100644 --- a/packages/web/src/local-first/Db.worker.ts +++ b/packages/web/src/local-first/Db.worker.ts @@ -1,18 +1,15 @@ /// declare const self: DedicatedWorkerGlobalScope; -import type { DbWorkerInput } from "@evolu/common/local-first"; +import { initDbWorker } from "@evolu/common/local-first"; +import { leaderLock } from "../Platform.js"; +import { createRun } from "../Task.js"; +import { createWorkerDeps, createWorkerSelf } from "../Worker.js"; -self.onmessage = (event: MessageEvent) => { - const message = event.data; +// TODO: Disposal. +const run = createRun({ + ...createWorkerDeps(), + leaderLock, +}); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (message.type === "init") { - // TODO: Add parallel stale-leader detection. - // Heartbeat is emitted by the active DB worker and sent to SharedWorker. - // SharedWorker tracks last-seen heartbeat per Evolu name and if silent for - // 10 seconds, it waits for another DB worker to announce itself alive and - // then routes requests to that worker. - return; - } -}; +run(initDbWorker(createWorkerSelf(self))); diff --git a/packages/web/src/local-first/Shared.worker.ts b/packages/web/src/local-first/Shared.worker.ts index bfa7d44e8..aaeab0694 100644 --- a/packages/web/src/local-first/Shared.worker.ts +++ b/packages/web/src/local-first/Shared.worker.ts @@ -2,9 +2,10 @@ declare const self: SharedWorkerGlobalScope; import { initSharedWorker } from "@evolu/common/local-first"; -import { createSharedWorkerSelf, createWorkerRun } from "../Worker.js"; +import { createRun } from "../Task.js"; +import { createSharedWorkerSelf, createWorkerDeps } from "../Worker.js"; // No disposal (`await using`) is needed — a SharedWorker lives forever. -const run = createWorkerRun(); +const run = createRun(createWorkerDeps()); run(initSharedWorker(createSharedWorkerSelf(self))); From cc87fde077e55ca02f2376f1c1a6af40483a5578 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 22:53:59 +0100 Subject: [PATCH 13/60] Add canonical test name constants Added testName next to Name in common type definitions. Added testAppName next to AppName in local-first Evolu module. Established canonical source exports for shared test identifiers. --- packages/common/src/Type.ts | 2 ++ packages/common/src/local-first/Evolu.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/common/src/Type.ts b/packages/common/src/Type.ts index 0b029a2fb..c308cfcd0 100644 --- a/packages/common/src/Type.ts +++ b/packages/common/src/Type.ts @@ -1664,6 +1664,8 @@ export const Name = /*#__PURE__*/ brand("Name", UrlSafeString, (value) => export type Name = typeof Name.Type; export interface NameError extends TypeError<"Name"> {} +export const testName = /*#__PURE__*/ Name.orThrow("Name"); + /** * Trimmed string between 8 and 64 characters, branded as `SimplePassword`. * diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index 3b3562062..d6503a625 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -208,6 +208,8 @@ export const AppName = /*#__PURE__*/ brand("AppName", UrlSafeString, (value) => export type AppName = typeof AppName.Type; export interface AppNameError extends TypeError<"AppName"> {} +export const testAppName = /*#__PURE__*/ AppName.orThrow("AppName"); + /** * Local-first SQL database with typed queries, mutations, and sync. * From 857608f998e9f80a2fe47798cf2b36ea4ac5fc05 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 22:54:28 +0100 Subject: [PATCH 14/60] Rename common test deps module Renamed test helper module from _deps.nodejs.ts to _deps.ts. Updated common test imports to use the renamed module path. Switched shared test name/app name usage to canonical source exports. Removed stale _deps references from local-first test comments. --- packages/common/test/Sqlite.test.ts | 4 +- packages/common/test/_deps.nodejs.ts | 114 ------------------ packages/common/test/_deps.ts | 2 +- .../common/test/local-first/Evolu.test.ts | 13 +- .../common/test/local-first/Protocol.test.ts | 2 +- .../common/test/local-first/Relay.test.ts | 2 +- .../common/test/local-first/Schema.test.ts | 2 +- .../common/test/local-first/Storage.test.ts | 2 +- 8 files changed, 13 insertions(+), 128 deletions(-) delete mode 100644 packages/common/test/_deps.nodejs.ts diff --git a/packages/common/test/Sqlite.test.ts b/packages/common/test/Sqlite.test.ts index 6e45026b4..9734f954f 100644 --- a/packages/common/test/Sqlite.test.ts +++ b/packages/common/test/Sqlite.test.ts @@ -17,8 +17,8 @@ import { } from "../src/Sqlite.js"; import { sleep } from "../src/Task.js"; import { testCreateRun } from "../src/Test.js"; -import { testName } from "./_deps.js"; -import { testCreateRunWithSqlite } from "./_deps.nodejs.js"; +import { testName } from "../src/Type.js"; +import { testCreateRunWithSqlite } from "./_deps.js"; describe("eqSqliteValue", () => { test("equal Uint8Arrays return true", () => { diff --git a/packages/common/test/_deps.nodejs.ts b/packages/common/test/_deps.nodejs.ts deleted file mode 100644 index caed797ca..000000000 --- a/packages/common/test/_deps.nodejs.ts +++ /dev/null @@ -1,114 +0,0 @@ -import BetterSQLite, { type Statement } from "better-sqlite3"; -import { timingSafeEqual } from "crypto"; -import { assert } from "../src/Assert.js"; -import type { TimingSafeEqual } from "../src/Crypto.js"; -import { lazyTrue, lazyVoid } from "../src/Function.js"; -import { - createRelaySqliteStorage, - createRelayStorageTables, -} from "../src/local-first/Relay.js"; -import { - createBaseSqliteStorageTables, - type StorageConfig, - type StorageDep, -} from "../src/local-first/Storage.js"; -import { ok } from "../src/Result.js"; -import type { - CreateSqliteDriver, - CreateSqliteDriverDep, - SqliteDep, - SqliteDriver, - SqliteRow, -} from "../src/Sqlite.js"; -import { createPreparedStatementsCache, createSqlite } from "../src/Sqlite.js"; -import type { Run } from "../src/Task.js"; -import { testCreateRun, type TestDeps } from "../src/Test.js"; -import { testName } from "./_deps.js"; - -export const testTimingSafeEqual: TimingSafeEqual = timingSafeEqual; - -export const testCreateRunWithSqlite = async (): Promise< - Run -> => { - const run = testCreateRun({ - createSqliteDriver: testCreateSqliteDriver, - }); - - const sqlite = await run(createSqlite(testName)); - assert(sqlite.ok, "bug"); - - run.defer(() => { - sqlite.value[Symbol.dispose](); - return ok(); - }); - - return run.addDeps({ sqlite: sqlite.value }); -}; - -/** Creates a test Run with relay storage and SQLite deps. */ -export const testCreateRunWithSqliteAndRelayStorage = async ( - config?: Partial, -): Promise> => { - const run = await testCreateRunWithSqlite(); - - createBaseSqliteStorageTables(run.deps); - createRelayStorageTables(run.deps); - - const storage = createRelaySqliteStorage({ - ...run.deps, - timingSafeEqual: testTimingSafeEqual, - })({ - onStorageError: (error) => { - throw new Error(error.type); - }, - isOwnerWithinQuota: lazyTrue, - ...config, - }); - - return run.addDeps({ storage }); -}; - -/** In-memory better-sqlite3 driver for tests. */ -const testCreateSqliteDriver: CreateSqliteDriver = (name) => - createBetterSqliteDriver(name, { mode: "memory" }); - -// Duplicated from @evolu/nodejs because @evolu/common cannot depend on it -// (nodejs depends on common — importing back would create a circular dependency). -const createBetterSqliteDriver: CreateSqliteDriver = (name, options) => () => { - const filename = options?.mode === "memory" ? ":memory:" : `${name}.db`; - const db = new BetterSQLite(filename); - let isDisposed = false; - - const cache = createPreparedStatementsCache( - (sql) => db.prepare(sql), - // Not needed. - // https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md#class-statement - lazyVoid, - ); - - const driver: SqliteDriver = { - exec: (query) => { - // Always prepare is recommended for better-sqlite3 - const prepared = cache.get(query, true); - - if (prepared.reader) { - const rows = prepared.all(query.parameters) as Array; - return { rows, changes: 0 }; - } - - const changes = prepared.run(query.parameters).changes; - return { rows: [], changes }; - }, - - export: () => db.serialize(), - - [Symbol.dispose]: () => { - if (isDisposed) return; - isDisposed = true; - cache[Symbol.dispose](); - db.close(); - }, - }; - - return ok(driver); -}; diff --git a/packages/common/test/_deps.ts b/packages/common/test/_deps.ts index caed797ca..56084185e 100644 --- a/packages/common/test/_deps.ts +++ b/packages/common/test/_deps.ts @@ -23,7 +23,7 @@ import type { import { createPreparedStatementsCache, createSqlite } from "../src/Sqlite.js"; import type { Run } from "../src/Task.js"; import { testCreateRun, type TestDeps } from "../src/Test.js"; -import { testName } from "./_deps.js"; +import { testName } from "../src/Type.js"; export const testTimingSafeEqual: TimingSafeEqual = timingSafeEqual; diff --git a/packages/common/test/local-first/Evolu.test.ts b/packages/common/test/local-first/Evolu.test.ts index af724a0a6..8715ebdff 100644 --- a/packages/common/test/local-first/Evolu.test.ts +++ b/packages/common/test/local-first/Evolu.test.ts @@ -3,16 +3,17 @@ import type { Brand } from "../../src/Brand.js"; import type { ConsoleEntry, TestConsole } from "../../src/Console.js"; import { testCreateConsole } from "../../src/Console.js"; import { lazyVoid } from "../../src/Function.js"; -import { - AppName, - createEvolu, - createEvoluDeps, -} from "../../src/local-first/Evolu.js"; import type { CreateDbWorker, DbWorker, DbWorkerInput, } from "../../src/local-first/Db.js"; +import { + AppName, + createEvolu, + createEvoluDeps, + testAppName, +} from "../../src/local-first/Evolu.js"; import type { EvoluInput, EvoluTabOutput, @@ -34,7 +35,6 @@ import { testCreateSharedWorker, testCreateWorker, } from "../../src/Worker.js"; -import { testAppName } from "../_deps.js"; import { testAppOwner } from "./_fixtures.js"; const TodoId = id("Todo"); @@ -529,7 +529,6 @@ describe("mutations", () => { // testRandomBytes, // testName, // testTime, -// } from "../_deps.js"; // const TodoId = id("Todo"); // type TodoId = InferType; diff --git a/packages/common/test/local-first/Protocol.test.ts b/packages/common/test/local-first/Protocol.test.ts index 88ecc19e5..1bd2d6232 100644 --- a/packages/common/test/local-first/Protocol.test.ts +++ b/packages/common/test/local-first/Protocol.test.ts @@ -67,7 +67,7 @@ import { NonNegativeInt, PositiveInt, } from "../../src/Type.js"; -import { testCreateRunWithSqliteAndRelayStorage } from "../_deps.nodejs.js"; +import { testCreateRunWithSqliteAndRelayStorage } from "../_deps.js"; import { maxTimestamp, testAppOwner, diff --git a/packages/common/test/local-first/Relay.test.ts b/packages/common/test/local-first/Relay.test.ts index eadc791da..12c0e79ef 100644 --- a/packages/common/test/local-first/Relay.test.ts +++ b/packages/common/test/local-first/Relay.test.ts @@ -17,7 +17,7 @@ import type { } from "../../src/local-first/Storage.js"; import { createInitialTimestamp } from "../../src/local-first/Timestamp.js"; import { testCreateDeps } from "../../src/Test.js"; -import { testCreateRunWithSqliteAndRelayStorage } from "../_deps.nodejs.js"; +import { testCreateRunWithSqliteAndRelayStorage } from "../_deps.js"; import { testAppOwner, testAppOwner2, diff --git a/packages/common/test/local-first/Schema.test.ts b/packages/common/test/local-first/Schema.test.ts index 1be4136b3..1369769c6 100644 --- a/packages/common/test/local-first/Schema.test.ts +++ b/packages/common/test/local-first/Schema.test.ts @@ -24,7 +24,7 @@ import { nullOr, type InferType, } from "../../src/Type.js"; -import { testCreateRunWithSqlite } from "../_deps.nodejs.js"; +import { testCreateRunWithSqlite } from "../_deps.js"; const TodoId = id("Todo"); type TodoId = typeof TodoId.Type; diff --git a/packages/common/test/local-first/Storage.test.ts b/packages/common/test/local-first/Storage.test.ts index 75f3a5e07..2b2debd0e 100644 --- a/packages/common/test/local-first/Storage.test.ts +++ b/packages/common/test/local-first/Storage.test.ts @@ -31,7 +31,7 @@ import { sql } from "../../src/Sqlite.js"; import { testCreateDeps } from "../../src/Test.js"; import type { Millis } from "../../src/Time.js"; import { createId, NonNegativeInt, PositiveInt } from "../../src/Type.js"; -import { testCreateRunWithSqlite } from "../_deps.nodejs.js"; +import { testCreateRunWithSqlite } from "../_deps.js"; import { testAnotherTimestampsAsc, testAppOwner2, From d5e7e3c03ac3d8775bcb6fd2d1dc77c9c0df1626 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 22:54:46 +0100 Subject: [PATCH 15/60] Add React Native leader lock tests Added leaderLock tests for React Native platform behavior. Verified lock acquisition waits for prior lease disposal for same name. Verified independent lock acquisition for different names. Reused shared testName constant from @evolu/common. --- packages/react-native/test/Platform.test.ts | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 packages/react-native/test/Platform.test.ts diff --git a/packages/react-native/test/Platform.test.ts b/packages/react-native/test/Platform.test.ts new file mode 100644 index 000000000..dd380ced1 --- /dev/null +++ b/packages/react-native/test/Platform.test.ts @@ -0,0 +1,48 @@ +import { createRun, Name, testName } from "@evolu/common"; +import { describe, expect, test } from "vitest"; +import { leaderLock } from "../src/Platform.js"; + +describe("leaderLock", () => { + test("acquire waits until previous lease is disposed", async () => { + await using run = createRun(); + + const first = await run(leaderLock.acquire(testName)); + expect(first.ok).toBe(true); + if (!first.ok) return; + + let secondSettled = false; + const second = run(leaderLock.acquire(testName)); + void second.then(() => { + secondSettled = true; + }); + + await Promise.resolve(); + expect(secondSettled).toBe(false); + + first.value[Symbol.dispose](); + + const secondResult = await second; + expect(secondResult.ok).toBe(true); + if (!secondResult.ok) return; + + secondResult.value[Symbol.dispose](); + }); + + test("different names acquire independently", async () => { + await using run = createRun(); + + const aName = Name.orThrow("LeaderLockA"); + const bName = Name.orThrow("LeaderLockB"); + + const [a, b] = await Promise.all([ + run(leaderLock.acquire(aName)), + run(leaderLock.acquire(bName)), + ]); + + expect(a.ok).toBe(true); + expect(b.ok).toBe(true); + + if (a.ok) a.value[Symbol.dispose](); + if (b.ok) b.value[Symbol.dispose](); + }); +}); From 27a34ec079d2e0434bd437f6ed1d0fdeef3706f1 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 23:10:56 +0100 Subject: [PATCH 16/60] Add web leader lock browser tests Added browser-mode integration tests for web leaderLock behavior. Covers same-name serialization and different-name independent acquisition. Uses shared testName constant for stable lock naming. --- packages/web/test/Platform.test.ts | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 packages/web/test/Platform.test.ts diff --git a/packages/web/test/Platform.test.ts b/packages/web/test/Platform.test.ts new file mode 100644 index 000000000..b4dfd6500 --- /dev/null +++ b/packages/web/test/Platform.test.ts @@ -0,0 +1,49 @@ +import { Name, testName } from "@evolu/common"; +import { describe, expect, test } from "vitest"; +import { leaderLock } from "../src/Platform.js"; +import { createRun } from "../src/Task.js"; + +describe("leaderLock", () => { + test("acquire waits until previous lease is disposed", async () => { + await using run = createRun(); + + const first = await run(leaderLock.acquire(testName)); + expect(first.ok).toBe(true); + if (!first.ok) return; + + let secondSettled = false; + const second = run(leaderLock.acquire(testName)); + void second.then(() => { + secondSettled = true; + }); + + await Promise.resolve(); + expect(secondSettled).toBe(false); + + first.value[Symbol.dispose](); + + const secondResult = await second; + expect(secondResult.ok).toBe(true); + if (!secondResult.ok) return; + + secondResult.value[Symbol.dispose](); + }); + + test("different names acquire independently", async () => { + await using run = createRun(); + + const aName = Name.orThrow("LeaderLockA"); + const bName = Name.orThrow("LeaderLockB"); + + const [a, b] = await Promise.all([ + run(leaderLock.acquire(aName)), + run(leaderLock.acquire(bName)), + ]); + + expect(a.ok).toBe(true); + expect(b.ok).toBe(true); + + if (a.ok) a.value[Symbol.dispose](); + if (b.ok) b.value[Symbol.dispose](); + }); +}); From cb1e7e01eab9650468f1dddbf4aa59bcb810231e Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sat, 14 Feb 2026 23:11:10 +0100 Subject: [PATCH 17/60] Use store-only worker console output Removed native console output from web createWorkerDeps. Kept console output routed through consoleStoreOutput only. Avoids duplicate logs when worker output is forwarded via shared worker. --- packages/web/src/Worker.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/web/src/Worker.ts b/packages/web/src/Worker.ts index c7b11d152..492007087 100644 --- a/packages/web/src/Worker.ts +++ b/packages/web/src/Worker.ts @@ -10,13 +10,7 @@ import type { WorkerDeps, WorkerSelf, } from "@evolu/common"; -import { - assert, - createConsole, - createConsoleStoreOutput, - createMultiOutput, - createNativeConsoleOutput, -} from "@evolu/common"; +import { assert, createConsole, createConsoleStoreOutput } from "@evolu/common"; /** Creates a {@link Worker} from a Web Worker. */ export const createWorker = ( @@ -109,10 +103,7 @@ export const createSharedWorkerSelf = ( export const createWorkerDeps = (): WorkerDeps => { const consoleStoreOutput = createConsoleStoreOutput(); const console = createConsole({ - output: createMultiOutput([ - createNativeConsoleOutput(), - consoleStoreOutput, - ]), + output: consoleStoreOutput, }); return { From f05ec759e23d2ca2214dc56cc6d10ae9c7543281 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 00:18:46 +0100 Subject: [PATCH 18/60] Use Run type for run.daemon Change the Run interface so the daemon property is typed as Run rather than a function signature, and update createRunInternal to assign run.daemon = daemon ?? self instead of wrapping it in a lambda. This aligns the runtime assignment with the new type, removes an unnecessary closure, and resolves related typing mismatches. --- packages/common/src/Task.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/common/src/Task.ts b/packages/common/src/Task.ts index e1a54a42d..0a2d55942 100644 --- a/packages/common/src/Task.ts +++ b/packages/common/src/Task.ts @@ -543,7 +543,7 @@ export interface Run extends AsyncDisposable { * }; * ``` */ - readonly daemon: (task: Task) => Fiber; + readonly daemon: Run; /** * Creates an {@link AsyncDisposable} that runs a cleanup callback or @@ -1409,7 +1409,7 @@ const createRunInternal = return snapshot; }; - run.daemon = (task) => (daemon ?? self)(task); + run.daemon = daemon ?? self; run.defer = (task) => ({ [Symbol.asyncDispose]: () => run.daemon(unabortable(task as Task)).then(lazyVoid), From af7aca4cb74d88ee5e2b61eb6a03b5295ec8f027 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 00:19:53 +0100 Subject: [PATCH 19/60] Update TreeShaking.test.ts --- packages/common/test/TreeShaking.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/common/test/TreeShaking.test.ts b/packages/common/test/TreeShaking.test.ts index c225511a4..33a4cb47b 100644 --- a/packages/common/test/TreeShaking.test.ts +++ b/packages/common/test/TreeShaking.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, @@ -5,7 +6,6 @@ import { readFileSync, rmSync, } from "node:fs"; -import { spawnSync } from "node:child_process"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { gzipSync } from "node:zlib"; @@ -141,8 +141,8 @@ describe("tree-shaking", () => { "raw": 1602, }, "task-example": { - "gzip": 5626, - "raw": 15095, + "gzip": 5621, + "raw": 15087, }, "type-object": { "gzip": 1549, From 2cc62722605e123e11568849aaa39aaaa075c41d Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 00:20:30 +0100 Subject: [PATCH 20/60] Sort imports --- packages/common/src/Crypto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/src/Crypto.ts b/packages/common/src/Crypto.ts index 4b7b79119..6f3b11781 100644 --- a/packages/common/src/Crypto.ts +++ b/packages/common/src/Crypto.ts @@ -12,6 +12,7 @@ import { xchacha20poly1305 } from "@noble/ciphers/chacha.js"; import { hmac } from "@noble/hashes/hmac.js"; import { sha512 } from "@noble/hashes/sha2.js"; import { randomBytes, utf8ToBytes } from "@noble/hashes/utils.js"; +import { arrayFrom } from "./Array.js"; import type { RandomLibDep } from "./Random.js"; import type { Result } from "./Result.js"; import { trySync } from "./Result.js"; @@ -22,7 +23,6 @@ import { Uint8Array, type Typed, } from "./Type.js"; -import { arrayFrom } from "./Array.js"; export interface RandomBytes { /** From 2a585dc8626ec984612c12d02a12cc2c016f5e93 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 00:24:16 +0100 Subject: [PATCH 21/60] Centralize deterministic test values Added deterministic test value exports to Test.ts (testEntropy32, testOwnerSecret, testAppOwner). Kept eager test values in the test utility module to avoid affecting runtime tree-shaking paths. Documented the rationale near the exports and placed them at the end of the file. --- packages/common/src/Test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/common/src/Test.ts b/packages/common/src/Test.ts index 3666bfb99..50d0d4d09 100644 --- a/packages/common/src/Test.ts +++ b/packages/common/src/Test.ts @@ -5,7 +5,8 @@ */ import { testCreateConsole, type TestConsoleDep } from "./Console.js"; -import { testCreateRandomBytes } from "./Crypto.js"; +import { Entropy32, testCreateRandomBytes } from "./Crypto.js"; +import { createAppOwner, OwnerSecret } from "./local-first/Owner.js"; import { testCreateRandom, testCreateRandomLib, @@ -87,3 +88,18 @@ export function testCreateRun(deps?: D): Run { const defaults = testCreateDeps(); return createRun({ ...defaults, ...deps } as TestDeps & D); } + +// Deterministic test values for reproducible fixtures. Keep eager test values +// here to avoid affecting tree-shaking baselines. +// Functions are ok. + +export const testEntropy32 = /*#__PURE__*/ Entropy32.orThrow( + new globalThis.Uint8Array([ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + ]), +); + +export const testOwnerSecret = /*#__PURE__*/ OwnerSecret.orThrow(testEntropy32); + +export const testAppOwner = /*#__PURE__*/ createAppOwner(testOwnerSecret); From fb46547fe6fac5d0465f7670445b9e7ea924a124 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 00:25:49 +0100 Subject: [PATCH 22/60] Use deterministic AppOwner in playground Configured the minimal playground to pass appOwner: Evolu.testAppOwner to createEvolu. Keeps owner identity stable across reloads for deterministic local behavior. Reuses shared test utility owner instead of ad-hoc inline owner creation. --- .../(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx b/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx index e0b1fc2d4..5a6725d68 100644 --- a/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx +++ b/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx @@ -64,6 +64,7 @@ const run = createRun(deps); const app = run( Evolu.createEvolu(AppSchema, { appName: Evolu.AppName.orThrow("minimal-example"), + appOwner: Evolu.testAppOwner, ...(process.env.NODE_ENV === "development" && { transports: [{ type: "WebSocket", url: "ws://localhost:4000" }], From fefc8e3bcc2579de0b25243c504f42e54bfd6646 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 00:26:31 +0100 Subject: [PATCH 23/60] Refactor db worker init flow Updated initDbWorker to return and manage AsyncDisposableStack directly without await using/move. Switched leader lock acquisition to run.daemon(...) and reused leaderLock from deps. Extracted initialization work into initializeDb(name) task and moved logging/TODO heartbeat note there. --- packages/common/src/local-first/Db.ts | 43 ++++++++++++++------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index 17c448487..04463abd6 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -6,7 +6,7 @@ import type { LeaderLockDep } from "../Platform.js"; import { ok } from "../Result.js"; -import type { Task } from "../Task.js"; +import type { AsyncDisposableStack, Task } from "../Task.js"; import type { Name } from "../Type.js"; import type { Worker, WorkerInitDep, WorkerSelf } from "../Worker.js"; @@ -27,11 +27,9 @@ export const initDbWorker = ( self: WorkerSelf, ): Task => - async (run) => { - const console = run.deps.console.child("DbWorker"); - // TODO: Wire console to shared worker somehow. - - await using stack = run.stack(); + (run) => { + const { leaderLock } = run.deps; + const stack = run.stack(); let initialized = false; @@ -39,24 +37,29 @@ export const initDbWorker = if (!initialized) { initialized = true; - void run(async (run) => { - const lease = await run(run.deps.leaderLock.acquire(message.name)); - if (!lease.ok) return lease; - - stack.use(lease.value); - return ok(); + void run.daemon(async (run) => { + await stack.use(leaderLock.acquire(message.name)); + return run(initializeDb(message.name)); }); } - - console.info("initDbWorker", { name: message.name }); - // TODO: Add parallel stale-leader detection. - // Heartbeat is emitted by the active DB worker and sent to - // SharedWorker. SharedWorker tracks last-seen heartbeat per Evolu - // name and if silent for 10 seconds, it waits for another DB worker - // to announce itself alive and then routes requests to that worker. }; - return ok(stack.move()); + return ok(stack); + }; + +const initializeDb = + (name: Name): Task => + (run) => { + const _console = run.deps.console.child("DbWorker"); + + globalThis.console.info("initializeDb", { name }); + // TODO: Add parallel stale-leader detection. + // Heartbeat is emitted by the active DB worker and sent to + // SharedWorker. SharedWorker tracks last-seen heartbeat per Evolu + // name and if silent for 10 seconds, it waits for another DB worker + // to announce itself alive and then routes requests to that worker. + + return ok(); }; // import { From db759d457ca5ef3f0777d86a210ef3f0459efc2a Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 01:55:14 +0100 Subject: [PATCH 24/60] Added broker channel wiring and leader acquired output handling --- packages/common/src/Worker.ts | 2 +- packages/common/src/local-first/Db.ts | 23 ++++++++++--- packages/common/src/local-first/Evolu.ts | 33 ++++++++++++------- packages/common/src/local-first/Shared.ts | 19 +++++++++++ .../common/test/local-first/Evolu.test.ts | 10 +++--- .../common/test/local-first/Shared.test.ts | 4 +++ 6 files changed, 71 insertions(+), 20 deletions(-) diff --git a/packages/common/src/Worker.ts b/packages/common/src/Worker.ts index 4c2985f5e..21c28e330 100644 --- a/packages/common/src/Worker.ts +++ b/packages/common/src/Worker.ts @@ -102,7 +102,7 @@ export interface CreateMessagePortDep { export type WorkerInitDep = ConsoleStoreOutputEntryDep & CreateMessagePortDep; /** Common dependencies for worker entry points. */ -export type WorkerDeps = WorkerInitDep & ConsoleDep; +export type WorkerDeps = ConsoleDep & WorkerInitDep; /** * Platform-agnostic MessageChannel. diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index 04463abd6..640018b5e 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -8,11 +8,17 @@ import type { LeaderLockDep } from "../Platform.js"; import { ok } from "../Result.js"; import type { AsyncDisposableStack, Task } from "../Task.js"; import type { Name } from "../Type.js"; -import type { Worker, WorkerInitDep, WorkerSelf } from "../Worker.js"; +import type { + NativeMessagePort, + Worker, + WorkerInitDep, + WorkerSelf, +} from "../Worker.js"; export interface DbWorkerInput { readonly type: "init"; readonly name: Name; + readonly brokerPort: NativeMessagePort; } export type DbWorker = Worker; @@ -23,6 +29,11 @@ export interface CreateDbWorkerDep { readonly createDbWorker: CreateDbWorker; } +export interface DbWorkerLeaderOutput { + readonly type: "LeaderAcquired"; + readonly name: Name; +} + export const initDbWorker = ( self: WorkerSelf, @@ -33,13 +44,17 @@ export const initDbWorker = let initialized = false; - self.onMessage = (message) => { + self.onMessage = ({ name, brokerPort: nativeBrokerPort }) => { if (!initialized) { initialized = true; + const brokerPort = + run.deps.createMessagePort(nativeBrokerPort); + void run.daemon(async (run) => { - await stack.use(leaderLock.acquire(message.name)); - return run(initializeDb(message.name)); + await stack.use(leaderLock.acquire(name)); + brokerPort.postMessage({ type: "LeaderAcquired", name }); + return run(initializeDb(name)); }); } }; diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index d6503a625..0595e7efb 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -29,7 +29,7 @@ import { UrlSafeString, } from "../Type.js"; import type { CreateMessageChannelDep } from "../Worker.js"; -import type { CreateDbWorkerDep } from "./Db.js"; +import type { CreateDbWorkerDep, DbWorkerLeaderOutput } from "./Db.js"; import type { EvoluError } from "./Error.js"; import type { AppOwner, OwnerTransport } from "./Owner.js"; import { @@ -495,16 +495,27 @@ export const createEvolu = await using stack = run.stack(); const dbWorker = stack.use(createDbWorker()); - dbWorker.postMessage({ type: "init", name }); - - const { - port1: { postMessage }, - port2, - } = stack.use(createMessageChannel()); - - sharedWorker.port.postMessage({ type: "InitEvolu", port: port2.native }, [ - port2.native, - ]); + const evoluChannel = stack.use(createMessageChannel()); + const brokerChannel = stack.use( + createMessageChannel(), + ); + + sharedWorker.port.postMessage( + { + type: "InitEvolu", + name, + port: evoluChannel.port2.native, + brokerPort: brokerChannel.port2.native, + }, + [evoluChannel.port2.native, brokerChannel.port2.native], + ); + + dbWorker.postMessage( + { type: "init", name, brokerPort: brokerChannel.port1.native }, + [brokerChannel.port1.native], + ); + + const { postMessage } = evoluChannel.port1; const mutateBatch = createMicrotaskBatch<{ readonly change: MutationChange; diff --git a/packages/common/src/local-first/Shared.ts b/packages/common/src/local-first/Shared.ts index 2663329bb..6e110c71f 100644 --- a/packages/common/src/local-first/Shared.ts +++ b/packages/common/src/local-first/Shared.ts @@ -10,6 +10,7 @@ import type { ConsoleEntry } from "../Console.js"; import { exhaustiveCheck } from "../Function.js"; import { ok } from "../Result.js"; import type { Task } from "../Task.js"; +import type { Name } from "../Type.js"; import type { SharedWorker as CommonSharedWorker, MessagePort, @@ -17,6 +18,7 @@ import type { SharedWorkerSelf, WorkerInitDep, } from "../Worker.js"; +import type { DbWorkerLeaderOutput } from "./Db.js"; import type { EvoluError } from "./Error.js"; import type { Query } from "./Query.js"; import type { MutationChange } from "./Schema.js"; @@ -49,7 +51,9 @@ export type SharedWorkerInput = | { /** Per-Evolu instance request channel. */ readonly type: "InitEvolu"; + readonly name: Name; readonly port: NativeMessagePort; + readonly brokerPort: NativeMessagePort; }; export type EvoluTabOutput = @@ -73,6 +77,10 @@ export const initSharedWorker = // TODO: Use heartbeat to detect and prune dead ports. const tabPorts = new Set>(); const queuedTabOutputs: Array = []; + const leaderPorts = new Map< + Name, + MessagePort + >(); const postTabOutput = (output: EvoluTabOutput): void => { for (const port of tabPorts) port.postMessage(output); @@ -117,6 +125,17 @@ export const initSharedWorker = const evoluPort = createMessagePort( message.port, ); + const brokerPort = createMessagePort( + message.brokerPort, + ); + + leaderPorts.set(message.name, brokerPort); + + brokerPort.onMessage = (leaderEvent) => { + leaderPorts.set(leaderEvent.name, brokerPort); + console.info("leaderAcquired", { name: leaderEvent.name }); + }; + evoluPort.onMessage = (message) => { console.log(message); }; diff --git a/packages/common/test/local-first/Evolu.test.ts b/packages/common/test/local-first/Evolu.test.ts index 8715ebdff..93c973048 100644 --- a/packages/common/test/local-first/Evolu.test.ts +++ b/packages/common/test/local-first/Evolu.test.ts @@ -317,10 +317,12 @@ describe("createEvolu", () => { if (!result.ok) return; expect(dbWorkerMessages).toHaveLength(1); - expect(dbWorkerMessages[0]).toEqual({ - type: "init", - name: result.value.name, - }); + expect(dbWorkerMessages[0]).toEqual( + expect.objectContaining({ + type: "init", + name: result.value.name, + }), + ); }); test("resolves name from appName and appOwner hash", async () => { diff --git a/packages/common/test/local-first/Shared.test.ts b/packages/common/test/local-first/Shared.test.ts index 3cdc32602..2017ff579 100644 --- a/packages/common/test/local-first/Shared.test.ts +++ b/packages/common/test/local-first/Shared.test.ts @@ -13,6 +13,7 @@ import { testCreateMessagePort, testCreateSharedWorker, } from "../../src/Worker.js"; +import { testName } from "../../src/Type.js"; import { describe, expect, test } from "vitest"; describe("initSharedWorker", () => { @@ -251,11 +252,14 @@ describe("initSharedWorker", () => { await using _workerStack = workerStack; const evoluChannel = testCreateMessageChannel(); + const brokerChannel = testCreateMessageChannel(); expect(() => { worker.port.postMessage({ type: "InitEvolu", + name: testName, port: evoluChannel.port1.native, + brokerPort: brokerChannel.port1.native, }); }).not.toThrow(); }); From a2a448db16aa1b4ecbfd414a756648d7e8026f04 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 11:24:40 +0100 Subject: [PATCH 25/60] Added typed NativeMessagePort generics Typed NativeMessagePort with input and output phantom types to make port transfer wiring type-safe across platforms. Updated platform worker adapters to preserve these types at boundaries. --- packages/common/src/Worker.ts | 33 +++++++++++++++++++++-------- packages/react-native/src/Worker.ts | 4 ++-- packages/web/src/Worker.ts | 2 +- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/common/src/Worker.ts b/packages/common/src/Worker.ts index 21c28e330..482bc9266 100644 --- a/packages/common/src/Worker.ts +++ b/packages/common/src/Worker.ts @@ -68,7 +68,7 @@ export interface MessagePort extends Disposable { * ); * ``` */ - readonly native: NativeMessagePort; + readonly native: NativeMessagePort; } /** @@ -78,7 +78,7 @@ export interface MessagePort extends Disposable { * transferable types (`ImageBitmap`, `OffscreenCanvas`, `ReadableStream`, etc.) * that can be added here if needed. */ -export type Transferable = NativeMessagePort | ArrayBuffer; +export type Transferable = NativeMessagePort | ArrayBuffer; /** * Opaque type for platform-specific native MessagePort. @@ -87,11 +87,20 @@ export type Transferable = NativeMessagePort | ArrayBuffer; * a wrapper. Ensures type-safe wiring between {@link MessagePort.native} and * {@link CreateMessagePort} without exposing platform details. */ -export type NativeMessagePort = Brand<"NativeMessagePort">; +export type NativeMessagePort< + Input = unknown, + Output = never, +> = Brand<"NativeMessagePort"> & { + readonly [nativeMessagePortInput]?: Input; + readonly [nativeMessagePortOutput]?: Output; +}; + +declare const nativeMessagePortInput: unique symbol; +declare const nativeMessagePortOutput: unique symbol; /** Factory function to create a {@link MessagePort} from a native port. */ export type CreateMessagePort = ( - nativePort: NativeMessagePort, + nativePort: NativeMessagePort, ) => MessagePort; export interface CreateMessagePortDep { @@ -292,8 +301,14 @@ export const testCreateMessageChannel = < const state1: TestPortState = { handler: null, queue: [] }; const state2: TestPortState = { handler: null, queue: [] }; - const native1 = Symbol("NativeMessagePort1") as unknown as NativeMessagePort; - const native2 = Symbol("NativeMessagePort2") as unknown as NativeMessagePort; + const native1 = Symbol("NativeMessagePort1") as unknown as NativeMessagePort< + Input, + Output + >; + const native2 = Symbol("NativeMessagePort2") as unknown as NativeMessagePort< + Output, + Input + >; const port1 = createTestPort(state1, state2, native1); const port2 = createTestPort(state2, state1, native2); @@ -317,7 +332,7 @@ export const testCreateMessageChannel = < /** Creates an in-memory {@link CreateMessagePort} for testing. */ export const testCreateMessagePort: CreateMessagePort = ( - nativePort: NativeMessagePort, + nativePort: NativeMessagePort, ): MessagePort => { const pair = nativePortRegistry.get(nativePort); if (!pair) throw new Error("Unknown native port — did you transfer it?"); @@ -332,7 +347,7 @@ interface TestPortState { const createTestPort = ( receive: TestPortState, peerReceive: TestPortState, - native: NativeMessagePort, + native: NativeMessagePort, ): MessagePort => ({ postMessage: (message) => { if (peerReceive.handler) peerReceive.handler(message); @@ -364,6 +379,6 @@ const createTestPort = ( * longer referenced. */ const nativePortRegistry = new WeakMap< - NativeMessagePort, + NativeMessagePort, MessagePort >(); diff --git a/packages/react-native/src/Worker.ts b/packages/react-native/src/Worker.ts index 4c1bfecce..5b34c652a 100644 --- a/packages/react-native/src/Worker.ts +++ b/packages/react-native/src/Worker.ts @@ -104,7 +104,7 @@ export const createMessageChannel = (): MessageChannel< * wrapper, so this is a passthrough. */ export const createMessagePort = ( - nativePort: NativeMessagePort, + nativePort: NativeMessagePort, ): MessagePort => nativePort as unknown as MessagePort; @@ -131,7 +131,7 @@ const createRnPort = ( for (const msg of receive.queue.splice(0)) fn(msg); } }, - native: null as unknown as NativeMessagePort, + native: null as unknown as NativeMessagePort, [Symbol.dispose]: () => { receive.handler = null; onDispose?.(); diff --git a/packages/web/src/Worker.ts b/packages/web/src/Worker.ts index 492007087..7a73e90bd 100644 --- a/packages/web/src/Worker.ts +++ b/packages/web/src/Worker.ts @@ -142,7 +142,7 @@ const wrap = ( } }, - native: native as unknown as NativeMessagePort, + native: native as unknown as NativeMessagePort, [Symbol.dispose]: () => { native.onmessage = null; From 746ea844343efee9c8b8a05d2b74b7dec067f5a0 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 11:25:05 +0100 Subject: [PATCH 26/60] Refactored local-first worker channel wiring Renamed init ports to port1 and port2, replaced broker naming with leader semantics, and scoped Evolu channel wiring for readability. Updated Db/Shared contracts and tests to match the clearer message flow. --- packages/common/src/local-first/Db.ts | 13 +++-- packages/common/src/local-first/Evolu.ts | 55 +++++++++++-------- packages/common/src/local-first/Shared.ts | 18 +++--- .../common/test/local-first/Evolu.test.ts | 2 +- .../common/test/local-first/Shared.test.ts | 13 +++-- 5 files changed, 57 insertions(+), 44 deletions(-) diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index 640018b5e..bf03ff124 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -18,7 +18,7 @@ import type { export interface DbWorkerInput { readonly type: "init"; readonly name: Name; - readonly brokerPort: NativeMessagePort; + readonly port: NativeMessagePort; } export type DbWorker = Worker; @@ -39,21 +39,22 @@ export const initDbWorker = self: WorkerSelf, ): Task => (run) => { - const { leaderLock } = run.deps; + const { leaderLock, createMessagePort } = run.deps; const stack = run.stack(); let initialized = false; - self.onMessage = ({ name, brokerPort: nativeBrokerPort }) => { + self.onMessage = ({ name, port: nativeLeaderPort }) => { if (!initialized) { initialized = true; - const brokerPort = - run.deps.createMessagePort(nativeBrokerPort); + const leaderPort = stack.use( + createMessagePort(nativeLeaderPort), + ); void run.daemon(async (run) => { await stack.use(leaderLock.acquire(name)); - brokerPort.postMessage({ type: "LeaderAcquired", name }); + leaderPort.postMessage({ type: "LeaderAcquired", name }); return run(initializeDb(name)); }); } diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index 0595e7efb..67addaf64 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -480,8 +480,6 @@ export const createEvolu = config: EvoluConfig, ): Task, never, EvoluPlatformDeps> => async (run) => { - const { createDbWorker, createMessageChannel, sharedWorker } = run.deps; - const { appName, appOwner = createAppOwner(createOwnerSecret(run.deps)) } = config; const name = Name.orThrow(`${appName}-${createIdFromString(appOwner.id)}`); @@ -494,28 +492,37 @@ export const createEvolu = await using stack = run.stack(); - const dbWorker = stack.use(createDbWorker()); - const evoluChannel = stack.use(createMessageChannel()); - const brokerChannel = stack.use( - createMessageChannel(), - ); - - sharedWorker.port.postMessage( - { - type: "InitEvolu", - name, - port: evoluChannel.port2.native, - brokerPort: brokerChannel.port2.native, - }, - [evoluChannel.port2.native, brokerChannel.port2.native], - ); - - dbWorker.postMessage( - { type: "init", name, brokerPort: brokerChannel.port1.native }, - [brokerChannel.port1.native], - ); - - const { postMessage } = evoluChannel.port1; + // Evolu instances send messages to SharedWorker. + let postMessage: (input: EvoluInput) => void; + + { + // Wire workers. + const { createDbWorker, createMessageChannel, sharedWorker } = run.deps; + // For DbWorker to announce SharedWorker it is the leader. + const leaderChannel = stack.use( + createMessageChannel(), + ); + // For Evolu to send and receive messages with SharedWorker. + const evoluChannel = stack.use(createMessageChannel()); + + sharedWorker.port.postMessage( + { + type: "InitEvolu", + name, + port1: evoluChannel.port2.native, + port2: leaderChannel.port2.native, + }, + [evoluChannel.port2.native, leaderChannel.port2.native], + ); + + stack + .use(createDbWorker()) + .postMessage({ type: "init", name, port: leaderChannel.port1.native }, [ + leaderChannel.port1.native, + ]); + + postMessage = evoluChannel.port1.postMessage; + } const mutateBatch = createMicrotaskBatch<{ readonly change: MutationChange; diff --git a/packages/common/src/local-first/Shared.ts b/packages/common/src/local-first/Shared.ts index 6e110c71f..206e34d57 100644 --- a/packages/common/src/local-first/Shared.ts +++ b/packages/common/src/local-first/Shared.ts @@ -46,14 +46,14 @@ export type SharedWorkerInput = | { /** Tab-level channel for broadcast outputs (console/error). */ readonly type: "InitTab"; - readonly port: NativeMessagePort; + readonly port: NativeMessagePort; } | { /** Per-Evolu instance request channel. */ readonly type: "InitEvolu"; readonly name: Name; - readonly port: NativeMessagePort; - readonly brokerPort: NativeMessagePort; + readonly port1: NativeMessagePort; + readonly port2: NativeMessagePort; }; export type EvoluTabOutput = @@ -123,16 +123,16 @@ export const initSharedWorker = // then set onMessage to start processing requests. // Messages are queued until onMessage is assigned. const evoluPort = createMessagePort( - message.port, + message.port1, ); - const brokerPort = createMessagePort( - message.brokerPort, + const leaderPort = createMessagePort( + message.port2, ); - leaderPorts.set(message.name, brokerPort); + leaderPorts.set(message.name, leaderPort); - brokerPort.onMessage = (leaderEvent) => { - leaderPorts.set(leaderEvent.name, brokerPort); + leaderPort.onMessage = (leaderEvent) => { + leaderPorts.set(leaderEvent.name, leaderPort); console.info("leaderAcquired", { name: leaderEvent.name }); }; diff --git a/packages/common/test/local-first/Evolu.test.ts b/packages/common/test/local-first/Evolu.test.ts index 93c973048..d828a6af1 100644 --- a/packages/common/test/local-first/Evolu.test.ts +++ b/packages/common/test/local-first/Evolu.test.ts @@ -71,7 +71,7 @@ const setupCreateEvolu = async () => { self.onConnect = (port) => { port.onMessage = (message) => { if (message.type !== "InitEvolu") return; - const evoluPort = testCreateMessagePort(message.port); + const evoluPort = testCreateMessagePort(message.port1); evoluPort.onMessage = (input) => { evoluInputs.push(input); }; diff --git a/packages/common/test/local-first/Shared.test.ts b/packages/common/test/local-first/Shared.test.ts index 2017ff579..4fceed88e 100644 --- a/packages/common/test/local-first/Shared.test.ts +++ b/packages/common/test/local-first/Shared.test.ts @@ -2,10 +2,12 @@ import { createStore } from "../../src/Store.js"; import type { ConsoleEntry } from "../../src/Console.js"; import type { ReadonlyStore } from "../../src/Store.js"; import { + type EvoluInput, type EvoluTabOutput, type SharedWorkerInput, initSharedWorker, } from "../../src/local-first/Shared.js"; +import type { DbWorkerLeaderOutput } from "../../src/local-first/Db.js"; import { testCreateConsole } from "../../src/Console.js"; import { testCreateRun } from "../../src/Test.js"; import { @@ -251,15 +253,18 @@ describe("initSharedWorker", () => { const { worker, workerStack } = await setupWorker(); await using _workerStack = workerStack; - const evoluChannel = testCreateMessageChannel(); - const brokerChannel = testCreateMessageChannel(); + const evoluChannel = testCreateMessageChannel(); + const leaderChannel = testCreateMessageChannel< + never, + DbWorkerLeaderOutput + >(); expect(() => { worker.port.postMessage({ type: "InitEvolu", name: testName, - port: evoluChannel.port1.native, - brokerPort: brokerChannel.port1.native, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, }); }).not.toThrow(); }); From 0b390f63ce31d2296867665b6449db33842b5e69 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 11:28:42 +0100 Subject: [PATCH 27/60] Clarified SharedWorker communication comment Documented that Evolu instance communication is routed through SharedWorker as the central point for observability and resilience mechanisms such as retry and timeout. --- packages/common/src/local-first/Evolu.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index 67addaf64..9ac6c5834 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -492,13 +492,14 @@ export const createEvolu = await using stack = run.stack(); - // Evolu instances send messages to SharedWorker. + // Used for all Evolu instance communication with SharedWorker. + // SharedWorker centralizes observability and resilience (retry, timeout). let postMessage: (input: EvoluInput) => void; { - // Wire workers. + // Wire SharedWorker and DbWorker channels. const { createDbWorker, createMessageChannel, sharedWorker } = run.deps; - // For DbWorker to announce SharedWorker it is the leader. + // For DbWorker to announce to SharedWorker that it is the leader. const leaderChannel = stack.use( createMessageChannel(), ); From 0bbef0af912637f725a0d743a2d780fdd0024276 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 11:55:08 +0100 Subject: [PATCH 28/60] Simplified worker deps and forwarded DbWorker console entries Removed WorkerInitDep in favor of a single WorkerDeps contract, wired DbWorker console store output through the leader channel, and updated SharedWorker handling/tests to forward DbWorker console entries to tab outputs. --- packages/common/src/Worker.ts | 8 +- packages/common/src/local-first/Db.ts | 38 ++++-- packages/common/src/local-first/Shared.ts | 22 +++- .../common/test/local-first/Shared.test.ts | 114 ++++++++++++++++++ 4 files changed, 161 insertions(+), 21 deletions(-) diff --git a/packages/common/src/Worker.ts b/packages/common/src/Worker.ts index 482bc9266..6ed7e8424 100644 --- a/packages/common/src/Worker.ts +++ b/packages/common/src/Worker.ts @@ -6,7 +6,6 @@ import type { Brand } from "./Brand.js"; import type { ConsoleDep, ConsoleStoreOutputEntryDep } from "./Console.js"; -import type { Task } from "./Task.js"; /** * Platform-agnostic Worker. @@ -107,11 +106,10 @@ export interface CreateMessagePortDep { readonly createMessagePort: CreateMessagePort; } -/** Common dependencies for worker initialization {@link Task}. */ -export type WorkerInitDep = ConsoleStoreOutputEntryDep & CreateMessagePortDep; - /** Common dependencies for worker entry points. */ -export type WorkerDeps = ConsoleDep & WorkerInitDep; +export type WorkerDeps = ConsoleDep & + ConsoleStoreOutputEntryDep & + CreateMessagePortDep; /** * Platform-agnostic MessageChannel. diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index bf03ff124..e0158264f 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -11,9 +11,10 @@ import type { Name } from "../Type.js"; import type { NativeMessagePort, Worker, - WorkerInitDep, + WorkerDeps, WorkerSelf, } from "../Worker.js"; +import type { ConsoleEntry } from "../Console.js"; export interface DbWorkerInput { readonly type: "init"; @@ -29,17 +30,22 @@ export interface CreateDbWorkerDep { readonly createDbWorker: CreateDbWorker; } -export interface DbWorkerLeaderOutput { - readonly type: "LeaderAcquired"; - readonly name: Name; -} +export type DbWorkerLeaderOutput = + | { + readonly type: "LeaderAcquired"; + readonly name: Name; + } + | { + readonly type: "ConsoleEntry"; + readonly entry: ConsoleEntry; + }; export const initDbWorker = ( self: WorkerSelf, - ): Task => + ): Task => (run) => { - const { leaderLock, createMessagePort } = run.deps; + const { leaderLock, createMessagePort, consoleStoreOutputEntry } = run.deps; const stack = run.stack(); let initialized = false; @@ -52,10 +58,17 @@ export const initDbWorker = createMessagePort(nativeLeaderPort), ); + stack.defer( + consoleStoreOutputEntry.subscribe(() => { + const entry = consoleStoreOutputEntry.get(); + if (entry) leaderPort.postMessage({ type: "ConsoleEntry", entry }); + }), + ); + void run.daemon(async (run) => { await stack.use(leaderLock.acquire(name)); leaderPort.postMessage({ type: "LeaderAcquired", name }); - return run(initializeDb(name)); + return run(startDbWorker(name)); }); } }; @@ -63,12 +76,13 @@ export const initDbWorker = return ok(stack); }; -const initializeDb = - (name: Name): Task => +const startDbWorker = + (name: Name): Task => (run) => { - const _console = run.deps.console.child("DbWorker"); + const console = run.deps.console.child(name).child("DbWorker"); + + console.info("initializeDb", { name }); - globalThis.console.info("initializeDb", { name }); // TODO: Add parallel stale-leader detection. // Heartbeat is emitted by the active DB worker and sent to // SharedWorker. SharedWorker tracks last-seen heartbeat per Evolu diff --git a/packages/common/src/local-first/Shared.ts b/packages/common/src/local-first/Shared.ts index 206e34d57..3e0e1fd94 100644 --- a/packages/common/src/local-first/Shared.ts +++ b/packages/common/src/local-first/Shared.ts @@ -16,7 +16,7 @@ import type { MessagePort, NativeMessagePort, SharedWorkerSelf, - WorkerInitDep, + WorkerDeps, } from "../Worker.js"; import type { DbWorkerLeaderOutput } from "./Db.js"; import type { EvoluError } from "./Error.js"; @@ -69,7 +69,7 @@ export type EvoluTabOutput = export const initSharedWorker = ( self: SharedWorkerSelf, - ): Task => + ): Task => async (run) => { const { createMessagePort, consoleStoreOutputEntry } = run.deps; const console = run.deps.console.child("SharedWorker"); @@ -132,8 +132,22 @@ export const initSharedWorker = leaderPorts.set(message.name, leaderPort); leaderPort.onMessage = (leaderEvent) => { - leaderPorts.set(leaderEvent.name, leaderPort); - console.info("leaderAcquired", { name: leaderEvent.name }); + switch (leaderEvent.type) { + case "LeaderAcquired": { + leaderPorts.set(leaderEvent.name, leaderPort); + console.info("leaderAcquired", { name: leaderEvent.name }); + break; + } + case "ConsoleEntry": { + postOrQueueTabOutput({ + type: "ConsoleEntry", + entry: leaderEvent.entry, + }); + break; + } + default: + exhaustiveCheck(leaderEvent); + } }; evoluPort.onMessage = (message) => { diff --git a/packages/common/test/local-first/Shared.test.ts b/packages/common/test/local-first/Shared.test.ts index 4fceed88e..9f881ec93 100644 --- a/packages/common/test/local-first/Shared.test.ts +++ b/packages/common/test/local-first/Shared.test.ts @@ -8,6 +8,7 @@ import { initSharedWorker, } from "../../src/local-first/Shared.js"; import type { DbWorkerLeaderOutput } from "../../src/local-first/Db.js"; +import type { MutationChange } from "../../src/local-first/Schema.js"; import { testCreateConsole } from "../../src/Console.js"; import { testCreateRun } from "../../src/Test.js"; import { @@ -269,6 +270,119 @@ describe("initSharedWorker", () => { }).not.toThrow(); }); + test("forwards DbWorker console entries from leader channel", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const tabChannel = testCreateMessageChannel(); + tabChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + port: tabChannel.port1.native, + }); + + const evoluChannel = testCreateMessageChannel(); + const leaderChannel = testCreateMessageChannel< + never, + DbWorkerLeaderOutput + >(); + + worker.port.postMessage({ + type: "InitEvolu", + name: testName, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, + }); + + const entry: ConsoleEntry = { + method: "info", + path: ["DbWorker"], + args: ["initializeDb", { name: testName }], + }; + + leaderChannel.port2.postMessage({ type: "ConsoleEntry", entry }); + + expect(receivedOutputs).toContainEqual({ type: "ConsoleEntry", entry }); + }); + + test("accepts LeaderAcquired events from leader channel", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + const evoluChannel = testCreateMessageChannel(); + const leaderChannel = testCreateMessageChannel< + never, + DbWorkerLeaderOutput + >(); + + worker.port.postMessage({ + type: "InitEvolu", + name: testName, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, + }); + + expect(() => { + leaderChannel.port2.postMessage({ + type: "LeaderAcquired", + name: testName, + }); + }).not.toThrow(); + }); + + test("accepts Evolu input messages on evolu channel", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + const evoluChannel = testCreateMessageChannel(); + const leaderChannel = testCreateMessageChannel< + never, + DbWorkerLeaderOutput + >(); + + worker.port.postMessage({ + type: "InitEvolu", + name: testName, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, + }); + + expect(() => { + evoluChannel.port2.postMessage({ + type: "mutate", + changes: [{} as MutationChange], + onCompleteIds: [], + subscribedQueries: [], + }); + }).not.toThrow(); + }); + + test("throws for unknown leader channel message type", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + const evoluChannel = testCreateMessageChannel(); + const leaderChannel = testCreateMessageChannel< + never, + DbWorkerLeaderOutput + >(); + + worker.port.postMessage({ + type: "InitEvolu", + name: testName, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, + }); + + expect(() => { + leaderChannel.port2.postMessage({ type: "Unknown" } as never); + }).toThrow(); + }); + test("throws for unknown message type", async () => { const { worker, workerStack } = await setupWorker(); await using _workerStack = workerStack; From 57d23902edfa4062fbfe2904e2479881ae3f5ee5 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 14:34:12 +0100 Subject: [PATCH 29/60] Wired DbWorker init payload and worker output flow Added full DbWorker init inputs (consoleLevel, dbSchema, encryptionKey) and wired them from createEvolu through worker channels. Unified leader output typing with EvoluTabOutput, added typed EvoluError forwarding, and updated SharedWorker InitTab to receive/apply consoleLevel. Updated schema conversion typing to avoid external casts, wired sqlite driver in web Db worker run deps, and aligned SharedWorker tests with the new InitTab contract. --- packages/common/src/local-first/Db.ts | 86 ++++++++++++++++--- packages/common/src/local-first/Evolu.ts | 25 ++++-- packages/common/src/local-first/Schema.ts | 8 +- packages/common/src/local-first/Shared.ts | 13 ++- .../common/test/local-first/Shared.test.ts | 8 ++ packages/web/src/Worker.ts | 4 +- packages/web/src/local-first/Db.worker.ts | 2 + 7 files changed, 118 insertions(+), 28 deletions(-) diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index e0158264f..ab035a4c7 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -4,21 +4,30 @@ * @module */ +import type { ConsoleLevel } from "../Console.js"; +import { createSlip21, EncryptionKey } from "../Crypto.js"; import type { LeaderLockDep } from "../Platform.js"; import { ok } from "../Result.js"; +import type { CreateSqliteDriverDep, SqliteDep } from "../Sqlite.js"; +import { createSqlite } from "../Sqlite.js"; import type { AsyncDisposableStack, Task } from "../Task.js"; import type { Name } from "../Type.js"; import type { + MessagePort, NativeMessagePort, Worker, WorkerDeps, WorkerSelf, } from "../Worker.js"; -import type { ConsoleEntry } from "../Console.js"; +import type { DbSchema } from "./Schema.js"; +import type { EvoluTabOutput } from "./Shared.js"; export interface DbWorkerInput { readonly type: "init"; readonly name: Name; + readonly consoleLevel: ConsoleLevel; + readonly dbSchema: DbSchema; + readonly encryptionKey: EncryptionKey; readonly port: NativeMessagePort; } @@ -30,29 +39,37 @@ export interface CreateDbWorkerDep { readonly createDbWorker: CreateDbWorker; } +export type DbWorkerDeps = WorkerDeps & LeaderLockDep & CreateSqliteDriverDep; + export type DbWorkerLeaderOutput = | { readonly type: "LeaderAcquired"; readonly name: Name; } - | { - readonly type: "ConsoleEntry"; - readonly entry: ConsoleEntry; - }; + | EvoluTabOutput; export const initDbWorker = ( self: WorkerSelf, - ): Task => + ): Task => (run) => { const { leaderLock, createMessagePort, consoleStoreOutputEntry } = run.deps; const stack = run.stack(); let initialized = false; - self.onMessage = ({ name, port: nativeLeaderPort }) => { + self.onMessage = ({ + name, + consoleLevel, + dbSchema, + encryptionKey, + port: nativeLeaderPort, + }) => { if (!initialized) { initialized = true; + const console = run.deps.console.child(name).child("DbWorker"); + console.setLevel(consoleLevel); + console.info("initDbWorker"); const leaderPort = stack.use( createMessagePort(nativeLeaderPort), @@ -67,8 +84,11 @@ export const initDbWorker = void run.daemon(async (run) => { await stack.use(leaderLock.acquire(name)); + console.info("leaderAcquired"); leaderPort.postMessage({ type: "LeaderAcquired", name }); - return run(startDbWorker(name)); + return run.addDeps({ leaderPort })( + startDbWorker(name, dbSchema, encryptionKey), + ); }); } }; @@ -76,22 +96,62 @@ export const initDbWorker = return ok(stack); }; +type StartDbWorkerDeps = DbWorkerDeps & { + readonly leaderPort: MessagePort; +}; + const startDbWorker = - (name: Name): Task => - (run) => { + ( + name: Name, + dbSchema: DbSchema, + encryptionKey: EncryptionKey, + ): Task => + async (run) => { + await using stack = run.stack(); const console = run.deps.console.child(name).child("DbWorker"); + const sqliteEncryptionKey = createSlip21(encryptionKey, [ + "evolu", + "sqlite", + 1, + name, + ]); + + const sqlite = await stack.use( + createSqlite(name, { + mode: "encrypted", + encryptionKey: EncryptionKey.orThrow(sqliteEncryptionKey), + }), + ); + + if (!sqlite.ok) { + run.deps.leaderPort.postMessage({ + type: "EvoluError", + error: sqlite.error, + }); + return ok(stack.move()); + } + + const runWithSqlite = run.addDeps({ sqlite: sqlite.value }); + + const initializeResult = await runWithSqlite(initializeDb(dbSchema)); + if (!initializeResult.ok) return initializeResult; - console.info("initializeDb", { name }); + console.info("sqliteReady"); + + return ok(stack.move()); // TODO: Add parallel stale-leader detection. // Heartbeat is emitted by the active DB worker and sent to // SharedWorker. SharedWorker tracks last-seen heartbeat per Evolu // name and if silent for 10 seconds, it waits for another DB worker // to announce itself alive and then routes requests to that worker. - - return ok(); }; +const initializeDb = + (_dbSchema: DbSchema): Task => + (_run) => + ok(); + // import { // firstInArray, // isNonEmptyArray, diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index 9ac6c5834..c466660c8 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -53,6 +53,7 @@ import type { MutationChange, ValidateSchema, } from "./Schema.js"; +import { evoluSchemaToDbSchema } from "./Schema.js"; import type { EvoluInput, EvoluTabOutput, SharedWorkerDep } from "./Shared.js"; import { DbChange } from "./Storage.js"; import type { SyncOwner } from "./Sync.js"; @@ -457,7 +458,11 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => { }; sharedWorker.port.postMessage( - { type: "InitTab", port: tabChannel.port1.native }, + { + type: "InitTab", + consoleLevel: console.getLevel(), + port: tabChannel.port1.native, + }, [tabChannel.port1.native], ); @@ -476,7 +481,7 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => { */ export const createEvolu = ( - _schema: ValidateSchema extends never ? S : ValidateSchema, + schema: ValidateSchema extends never ? S : ValidateSchema, config: EvoluConfig, ): Task, never, EvoluPlatformDeps> => async (run) => { @@ -516,11 +521,17 @@ export const createEvolu = [evoluChannel.port2.native, leaderChannel.port2.native], ); - stack - .use(createDbWorker()) - .postMessage({ type: "init", name, port: leaderChannel.port1.native }, [ - leaderChannel.port1.native, - ]); + stack.use(createDbWorker()).postMessage( + { + type: "init", + name, + consoleLevel: console.getLevel(), + dbSchema: evoluSchemaToDbSchema(schema, config.indexes), + encryptionKey: appOwner.encryptionKey, + port: leaderChannel.port1.native, + }, + [leaderChannel.port1.native], + ); postMessage = evoluChannel.port1.postMessage; } diff --git a/packages/common/src/local-first/Schema.ts b/packages/common/src/local-first/Schema.ts index 5d98c52ea..a162c3b89 100644 --- a/packages/common/src/local-first/Schema.ts +++ b/packages/common/src/local-first/Schema.ts @@ -385,12 +385,14 @@ export const systemColumnsWithId = /*#__PURE__*/ readonly([ "id", ]); -export const evoluSchemaToDbSchema = ( - schema: EvoluSchema, +export const evoluSchemaToDbSchema = ( + schema: ValidateSchema extends never ? S : ValidateSchema, indexesConfig?: IndexesConfig, ): DbSchema => { + const validSchema = schema as EvoluSchema; + const tables = mapObject( - schema, + validSchema, (table) => new Set(Object.keys(table).filter((k) => k !== "id")), ); diff --git a/packages/common/src/local-first/Shared.ts b/packages/common/src/local-first/Shared.ts index 3e0e1fd94..4f4f4dd5d 100644 --- a/packages/common/src/local-first/Shared.ts +++ b/packages/common/src/local-first/Shared.ts @@ -6,7 +6,7 @@ import type { NonEmptyReadonlyArray } from "../Array.js"; import type { CallbackId } from "../Callbacks.js"; -import type { ConsoleEntry } from "../Console.js"; +import type { ConsoleEntry, ConsoleLevel } from "../Console.js"; import { exhaustiveCheck } from "../Function.js"; import { ok } from "../Result.js"; import type { Task } from "../Task.js"; @@ -46,6 +46,7 @@ export type SharedWorkerInput = | { /** Tab-level channel for broadcast outputs (console/error). */ readonly type: "InitTab"; + readonly consoleLevel: ConsoleLevel; readonly port: NativeMessagePort; } | { @@ -103,11 +104,12 @@ export const initSharedWorker = console.info("initSharedWorker"); self.onConnect = (port) => { - console.info("onConnect"); + console.debug("onConnect"); port.onMessage = (message) => { switch (message.type) { case "InitTab": { + console.setLevel(message.consoleLevel); const tabPort = createMessagePort(message.port); tabPorts.add(tabPort); @@ -145,6 +147,13 @@ export const initSharedWorker = }); break; } + case "EvoluError": { + postOrQueueTabOutput({ + type: "EvoluError", + error: leaderEvent.error, + }); + break; + } default: exhaustiveCheck(leaderEvent); } diff --git a/packages/common/test/local-first/Shared.test.ts b/packages/common/test/local-first/Shared.test.ts index 9f881ec93..a884fad74 100644 --- a/packages/common/test/local-first/Shared.test.ts +++ b/packages/common/test/local-first/Shared.test.ts @@ -67,6 +67,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: consoleChannel.port1.native, }); @@ -97,6 +98,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: consoleChannel.port1.native, }); @@ -126,6 +128,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: consoleChannel.port1.native, }); @@ -154,6 +157,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: consoleChannel.port1.native, }); @@ -182,6 +186,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: consoleChannel.port1.native, }); @@ -209,6 +214,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: consoleChannel.port1.native, }); @@ -236,6 +242,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: consoleChannel.port1.native, }); @@ -282,6 +289,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: tabChannel.port1.native, }); diff --git a/packages/web/src/Worker.ts b/packages/web/src/Worker.ts index 7a73e90bd..d483ae38e 100644 --- a/packages/web/src/Worker.ts +++ b/packages/web/src/Worker.ts @@ -102,9 +102,7 @@ export const createSharedWorkerSelf = ( /** Creates deps shared by web worker entry points. */ export const createWorkerDeps = (): WorkerDeps => { const consoleStoreOutput = createConsoleStoreOutput(); - const console = createConsole({ - output: consoleStoreOutput, - }); + const console = createConsole({ output: consoleStoreOutput }); return { console, diff --git a/packages/web/src/local-first/Db.worker.ts b/packages/web/src/local-first/Db.worker.ts index 1e1da98a0..e8faaaedc 100644 --- a/packages/web/src/local-first/Db.worker.ts +++ b/packages/web/src/local-first/Db.worker.ts @@ -3,12 +3,14 @@ declare const self: DedicatedWorkerGlobalScope; import { initDbWorker } from "@evolu/common/local-first"; import { leaderLock } from "../Platform.js"; +import { createWasmSqliteDriver } from "../Sqlite.js"; import { createRun } from "../Task.js"; import { createWorkerDeps, createWorkerSelf } from "../Worker.js"; // TODO: Disposal. const run = createRun({ ...createWorkerDeps(), + createSqliteDriver: createWasmSqliteDriver, leaderLock, }); From d4f0e5f4142c86a0ca1a0d6d3600c61ad411219a Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 14:34:49 +0100 Subject: [PATCH 30/60] Wired sqlite driver into React Native deps Updated React Native shared dependency creation to require and pass CreateSqliteDriverDep into worker runtime deps. Updated the Expo SQLite export to provide createExpoSqliteDriver when creating Evolu deps. --- packages/react-native/src/exports/expo-sqlite.ts | 2 ++ packages/react-native/src/shared.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react-native/src/exports/expo-sqlite.ts b/packages/react-native/src/exports/expo-sqlite.ts index e6369bb9b..bef26ffb7 100644 --- a/packages/react-native/src/exports/expo-sqlite.ts +++ b/packages/react-native/src/exports/expo-sqlite.ts @@ -8,10 +8,12 @@ import type { EvoluDeps } from "@evolu/common/local-first"; import * as Expo from "expo"; import { createEvoluDeps as createSharedEvoluDeps } from "../shared.js"; +import { createExpoSqliteDriver } from "../sqlite-drivers/createExpoSqliteDriver.js"; /** Creates Evolu dependencies for Expo. */ export const createEvoluDeps = (): EvoluDeps => createSharedEvoluDeps({ + createSqliteDriver: createExpoSqliteDriver, reloadApp: () => { void Expo.reloadAppAsync(); }, diff --git a/packages/react-native/src/shared.ts b/packages/react-native/src/shared.ts index a73baf1c2..554c36743 100644 --- a/packages/react-native/src/shared.ts +++ b/packages/react-native/src/shared.ts @@ -4,6 +4,7 @@ import { createRandomBytes, createRun, type ConsoleDep, + type CreateSqliteDriverDep, type LocalAuth, type ReloadAppDep, type SecureStorage, @@ -32,7 +33,7 @@ const randomBytes = createRandomBytes(); /** Creates Evolu dependencies for React Native. */ export const createEvoluDeps = ( - deps: ReloadAppDep & Partial, + deps: ReloadAppDep & CreateSqliteDriverDep & Partial, ): EvoluDeps => { const consoleStoreOutput = createConsoleStoreOutput(); @@ -41,6 +42,7 @@ export const createEvoluDeps = ( const workerRun = createRun({ consoleStoreOutputEntry: consoleStoreOutput.entry, createMessagePort, + createSqliteDriver: deps.createSqliteDriver, leaderLock, }); From 096326e87273edba246a89f4b0b0f27d765273e6 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 14:35:18 +0100 Subject: [PATCH 31/60] Documented type aliases for composed deps Added DI FAQ guidance explaining why composed dependency contracts should use type aliases instead of interfaces, with examples of declaration merging risk and preferred usage. --- .../(docs)/docs/dependency-injection/page.mdx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/web/src/app/(docs)/docs/dependency-injection/page.mdx b/apps/web/src/app/(docs)/docs/dependency-injection/page.mdx index cfa67de18..ded9718bd 100644 --- a/apps/web/src/app/(docs)/docs/dependency-injection/page.mdx +++ b/apps/web/src/app/(docs)/docs/dependency-injection/page.mdx @@ -483,3 +483,19 @@ By hiding the generic, the `Storage` interface becomes implementation-agnostic. - **Decoupling:** Code remains agnostic to underlying implementation (SQLite, IndexedDB, in-memory, etc.) - **Simplicity:** Consumers don't need to know implementation-specific types - **Testability:** Easy to mock without matching generic parameters + +**Why use type aliases for composed deps instead of interfaces?** + +Use `type` for dependency compositions like `A & B & Partial`. Interfaces can be declaration-merged across files, which is useful for some extensibility patterns but risky for dependency contracts because accidental merges can silently change required deps. + +```ts +// Prefer: closed composition +type AppDeps = LoggerDep & TimeDep & Partial; + +// Avoid for composed deps: interface can be merged elsewhere +interface AppDeps extends LoggerDep, TimeDep { + readonly metrics?: Metrics; +} +``` + +Use interfaces for concrete dependency contracts (`Time`, `Logger`, `Storage`) and dep wrappers (`TimeDep`, `LoggerDep`). Use type aliases when combining multiple deps. From 7f120412c2485a337bc10c89eb11ba915df19576 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 18:06:24 +0100 Subject: [PATCH 32/60] Refactored DB worker startup to initialize schema and bootstrap clock state --- packages/common/src/local-first/Db.ts | 213 +++++++++++++++++++++----- 1 file changed, 177 insertions(+), 36 deletions(-) diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index ab035a4c7..a585371e4 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -5,11 +5,18 @@ */ import type { ConsoleLevel } from "../Console.js"; -import { createSlip21, EncryptionKey } from "../Crypto.js"; +import { firstInArray } from "../Array.js"; +import { assertNonEmptyReadonlyArray } from "../Assert.js"; +import { EncryptionKey, type RandomBytesDep } from "../Crypto.js"; import type { LeaderLockDep } from "../Platform.js"; +import type { Result } from "../Result.js"; import { ok } from "../Result.js"; -import type { CreateSqliteDriverDep, SqliteDep } from "../Sqlite.js"; -import { createSqlite } from "../Sqlite.js"; +import type { + CreateSqliteDriverDep, + SqliteDep, + SqliteError, +} from "../Sqlite.js"; +import { createSqlite, sql } from "../Sqlite.js"; import type { AsyncDisposableStack, Task } from "../Task.js"; import type { Name } from "../Type.js"; import type { @@ -19,8 +26,18 @@ import type { WorkerDeps, WorkerSelf, } from "../Worker.js"; +import { protocolVersion } from "./Protocol.js"; import type { DbSchema } from "./Schema.js"; +import { getDbSchema } from "./Schema.js"; import type { EvoluTabOutput } from "./Shared.js"; +import { createBaseSqliteStorageTables } from "./Storage.js"; +import type { Timestamp } from "./Timestamp.js"; +import { + createInitialTimestamp, + TimestampBytes, + timestampBytesToTimestamp, + timestampToTimestampBytes, +} from "./Timestamp.js"; export interface DbWorkerInput { readonly type: "init"; @@ -48,6 +65,10 @@ export type DbWorkerLeaderOutput = } | EvoluTabOutput; +export interface PortDep { + readonly port: MessagePort; +} + export const initDbWorker = ( self: WorkerSelf, @@ -71,22 +92,22 @@ export const initDbWorker = console.setLevel(consoleLevel); console.info("initDbWorker"); - const leaderPort = stack.use( + const port = stack.use( createMessagePort(nativeLeaderPort), ); stack.defer( consoleStoreOutputEntry.subscribe(() => { const entry = consoleStoreOutputEntry.get(); - if (entry) leaderPort.postMessage({ type: "ConsoleEntry", entry }); + if (entry) port.postMessage({ type: "ConsoleEntry", entry }); }), ); void run.daemon(async (run) => { await stack.use(leaderLock.acquire(name)); console.info("leaderAcquired"); - leaderPort.postMessage({ type: "LeaderAcquired", name }); - return run.addDeps({ leaderPort })( + port.postMessage({ type: "LeaderAcquired", name }); + return run.addDeps({ port })( startDbWorker(name, dbSchema, encryptionKey), ); }); @@ -96,47 +117,54 @@ export const initDbWorker = return ok(stack); }; -type StartDbWorkerDeps = DbWorkerDeps & { - readonly leaderPort: MessagePort; -}; - const startDbWorker = ( name: Name, - dbSchema: DbSchema, + _dbSchema: DbSchema, encryptionKey: EncryptionKey, - ): Task => + ): Task => async (run) => { await using stack = run.stack(); - const console = run.deps.console.child(name).child("DbWorker"); - const sqliteEncryptionKey = createSlip21(encryptionKey, [ - "evolu", - "sqlite", - 1, - name, - ]); + const { port } = run.deps; + const _console = run.deps.console.child(name).child("DbWorker"); const sqlite = await stack.use( - createSqlite(name, { - mode: "encrypted", - encryptionKey: EncryptionKey.orThrow(sqliteEncryptionKey), - }), + createSqlite(name, { mode: "encrypted", encryptionKey }), ); - if (!sqlite.ok) { - run.deps.leaderPort.postMessage({ - type: "EvoluError", - error: sqlite.error, - }); + port.postMessage({ type: "EvoluError", error: sqlite.error }); return ok(stack.move()); } - const runWithSqlite = run.addDeps({ sqlite: sqlite.value }); + const deps = { ...run.deps, sqlite: sqlite.value }; + + const result = sqlite.value.transaction(() => { + const dbSchema = getDbSchema(deps)(); + if (!dbSchema.ok) return dbSchema; + + const dbIsInitialized = "evolu_version" in dbSchema.value.tables; - const initializeResult = await runWithSqlite(initializeDb(dbSchema)); - if (!initializeResult.ok) return initializeResult; + const clockResult = createClock(deps)(dbIsInitialized); + if (!clockResult.ok) return clockResult; - console.info("sqliteReady"); + const clock = clockResult.value; + + if (!dbIsInitialized) { + const result = initializeDb(deps)(clock.get()); + if (!result.ok) return result; + } + + return ok(); + }); + + if (!result.ok) { + port.postMessage({ type: "EvoluError", error: result.error }); + return ok(stack.move()); + } + + // run.deps.leaderPort.onMessage({ + // // + // }) return ok(stack.move()); @@ -148,9 +176,122 @@ const startDbWorker = }; const initializeDb = - (_dbSchema: DbSchema): Task => - (_run) => - ok(); + ({ sqlite }: SqliteDep) => + (initialClock: Timestamp): Result => { + for (const query of [ + sql` + create table evolu_version ( + "protocolVersion" integer not null + ) + strict; + `, + + sql` + insert into evolu_version ("protocolVersion") + values (${protocolVersion}); + `, + + sql` + create table evolu_config ( + "clock" blob not null + ) + strict; + `, + + sql` + insert into evolu_config ("clock") + values (${timestampToTimestampBytes(initialClock)}); + `, + + /** + * The History table stores all values per ownerId, timestamp, table, id, + * and column for conflict-free merging using last-write-win CRDT. + * Denormalizes Timestamp and DbChange for covering index performance. + * Time travel is available when last-write-win isn't desired. Future + * optimization will store history more efficiently. + */ + sql` + create table evolu_history ( + "ownerId" blob not null, + "table" text not null, + "id" blob not null, + "column" text not null, + "timestamp" blob not null, + "value" any + ) + strict; + `, + + // Index for reading database changes by owner and timestamp. + sql` + create index evolu_history_ownerId_timestamp on evolu_history ( + "ownerId", + "timestamp" + ); + `, + + sql` + create unique index evolu_history_ownerId_table_id_column_timestampDesc on evolu_history ( + "ownerId", + "table", + "id", + "column", + "timestamp" desc + ); + `, + ]) { + const queryResult = sqlite.exec(query); + if (!queryResult.ok) return queryResult; + } + + return createBaseSqliteStorageTables({ sqlite }); + }; + +interface Clock { + readonly get: () => Timestamp; + readonly save: (timestamp: Timestamp) => Result; +} + +// interface ClockDep { +// readonly clock: Clock; +// } + +const createClock = + (deps: RandomBytesDep & SqliteDep) => + (dbIsInitialized: boolean): Result => { + let currentTimestamp: Timestamp; + + if (dbIsInitialized) { + const configResult = deps.sqlite.exec<{ clock: TimestampBytes }>(sql` + select clock + from evolu_config + limit 1; + `); + if (!configResult.ok) return configResult; + + assertNonEmptyReadonlyArray(configResult.value.rows); + const config = firstInArray(configResult.value.rows); + currentTimestamp = timestampBytesToTimestamp(config.clock); + } else { + currentTimestamp = createInitialTimestamp(deps); + } + + return ok({ + get: () => currentTimestamp, + + save: (timestamp) => { + currentTimestamp = timestamp; + + const result = deps.sqlite.exec(sql.prepared` + update evolu_config + set "clock" = ${timestampToTimestampBytes(timestamp)}; + `); + if (!result.ok) return result; + + return ok(); + }, + }); + }; // import { // firstInArray, From f94006d713d2621742ef5956d0160932d1f5dbf9 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 22:27:34 +0100 Subject: [PATCH 33/60] Refactored SQLite APIs to throw-first semantics and aligned protocol/storage boundaries - Removed SqliteError from SQLite public APIs and switched infra failure handling to throws - Kept Result for domain/protocol outcomes and mapped boundary failures to protocol-level errors - Added Sqlite.transaction overload for callbacks returning Result or void - Simplified storage/query/schema/sync call sites and updated relay/client boundary behavior - Updated docs/changeset and tests across common, nodejs, and web to match new semantics - Follow-up cleanup removed redundant Result leftovers in storage helpers --- .changeset/sqlite-refactor.md | 28 +- .../(docs)/docs/dependency-injection/page.mdx | 28 +- packages/common/src/Result.ts | 8 +- packages/common/src/Sqlite.ts | 175 +++++-------- packages/common/src/local-first/Db.ts | 44 +--- packages/common/src/local-first/Error.ts | 2 - packages/common/src/local-first/Protocol.ts | 211 +++++++++------ packages/common/src/local-first/Query.ts | 13 +- packages/common/src/local-first/Relay.ts | 243 +++++++----------- packages/common/src/local-first/Schema.ts | 118 ++++----- packages/common/src/local-first/Storage.ts | 198 ++++---------- packages/common/src/local-first/Sync.ts | 138 ++++------ packages/common/test/Sqlite.test.ts | 109 +++----- packages/common/test/_deps.ts | 3 - .../common/test/local-first/Protocol.test.ts | 6 +- .../common/test/local-first/Relay.test.ts | 17 +- .../common/test/local-first/Schema.test.ts | 76 ++---- .../common/test/local-first/Storage.test.ts | 36 +-- packages/common/test/local-first/_utils.ts | 13 +- packages/nodejs/src/local-first/Relay.ts | 21 +- packages/nodejs/test/Sqlite.test.ts | 25 +- packages/web/test/Sqlite.test.ts | 16 +- 22 files changed, 596 insertions(+), 932 deletions(-) diff --git a/.changeset/sqlite-refactor.md b/.changeset/sqlite-refactor.md index 022b6b1c4..d2fa7dacd 100644 --- a/.changeset/sqlite-refactor.md +++ b/.changeset/sqlite-refactor.md @@ -5,11 +5,31 @@ "@evolu/react-native": major --- -Refactored createSqlite and CreateSqliteDriver to use Task +Refactored SQLite integration to use Task and throw-first semantics -- Changed `createSqlite` to `Task` -- Changed `CreateSqliteDriver` to `Task` +- Changed `createSqlite` to `Task` +- Changed `CreateSqliteDriver` to `Task` +- Removed `SqliteError` from SQLite driver/task APIs +- Changed `Sqlite.exec` to return `SqliteExecResult` directly (no `Result<..., SqliteError>`) +- Changed `Sqlite.transaction` to support callbacks returning either `Result` or `void` (no `SqliteError` in the error channel) +- Changed `Sqlite.export` to return `Uint8Array` directly (no `Result<..., SqliteError>`) - Simplified `SqliteDriver.exec` by removing the `isMutation` parameter — the driver now determines read vs write internally - Replaced `options.memory` and `options.encryptionKey` with a discriminated `options.mode` field (`"memory"` | `"encrypted"`) -- Added 100% test coverage for better-sqlite3 and WASM SQLite drivers - Updated Expo and op-sqlite drivers to match the new API + +Why `SqliteError` was removed: + +- In Evolu, SQLite runs in-process. Failures are infrastructure-level and unrecoverable at the call site. +- Wrapping these failures as `Result` values did not create meaningful recovery paths; callers still had to fail. +- The correct behavior is to let such failures throw and surface them through platform `createRun` global handlers (web, nodejs, react-native), which report uncaught errors via Evolu `console.error`. +- Evolu also propagates `console.error` entries through its messaging layer into the shared `evoluError` global store, so app-level error subscriptions still receive these failures. + +Boundary handling: + +- At protocol boundaries (for example Protocol ↔ Storage), error handling remains explicit. +- Since storage implementations may throw, boundary code uses `try/catch`, logs with `console.error(error)`, and returns protocol-level outcomes. +- Protocol handles all thrown errors as boundary concerns, without coupling to SQLite-specific error types. + +Developer experience: + +- This change reduces boilerplate (`if (!result.ok)` branches and `SqliteError` plumbing), making infrastructure code less verbose and easier to follow. diff --git a/apps/web/src/app/(docs)/docs/dependency-injection/page.mdx b/apps/web/src/app/(docs)/docs/dependency-injection/page.mdx index ded9718bd..b977521d2 100644 --- a/apps/web/src/app/(docs)/docs/dependency-injection/page.mdx +++ b/apps/web/src/app/(docs)/docs/dependency-injection/page.mdx @@ -281,7 +281,7 @@ Btw, Evolu provides [Console](/docs/api-reference/common/Console). ## Error Handling -Dependencies often perform operations that can fail. Use the [`Result`](/docs/api-reference/common/Result/type-aliases/Result) type to make errors explicit and type-safe. The key principle: **expose domain errors, hide implementation errors**. +Dependencies often perform operations that can fail. Use the [`Result`](/docs/api-reference/common/Result/type-aliases/Result) type to make errors explicit and type-safe. The key principle: **expose domain errors, not implementation errors**. ```ts import { Result, ok, err } from "@evolu/common"; @@ -307,32 +307,6 @@ export interface StorageDep { } ``` -The implementation maps internal errors to domain errors: - -```ts -// Avoid: Leaking implementation error (SqliteError) through interface -export interface Storage { - readonly save: (data: Data) => Result; -} - -// Good: Map implementation errors to domain errors -export const createStorage = (deps: SqliteDep): Storage => ({ - save: (data) => { - const result = deps.sqlite.exec(/* ... */); - if (!result.ok) { - // Map SqliteError to a domain error - if (result.error.code === "SQLITE_FULL") { - return err({ type: "StorageFullError" }); - } - return err({ type: "PermissionError" }); - } - return ok(); - }, -}); -``` - -This approach keeps your domain logic decoupled from implementation details. You can swap SQLite for IndexedDB without changing the `Storage` interface or any code that depends on it. - ### Testing Error Paths With typed errors, testing failure scenarios is straightforward: diff --git a/packages/common/src/Result.ts b/packages/common/src/Result.ts index d731ab37d..e04a55ba8 100644 --- a/packages/common/src/Result.ts +++ b/packages/common/src/Result.ts @@ -182,9 +182,9 @@ import type { Typed } from "./Type.js"; * handleable at the app level. Group them in a union type like `AppError`: * * ```ts - * type AppError = SqliteError | SyncError | UnknownError; + * type AppError = TimestampError | SyncError | UnknownError; * - * interface SqliteError extends Typed<"SqliteError"> { + * interface TimestampError extends Typed<"TimestampError"> { * readonly error: UnknownError; * } * ``` @@ -197,9 +197,9 @@ import type { Typed } from "./Type.js"; * ```ts * const handleAppError = (error: AppError): void => { * switch (error.type) { - * case "SqliteError": + * case "TimestampError": * console.error(error.error.stack); // Log preserved stack trace - * showToast("Database error. Please restart the app."); + * showToast("Timestamp error. Your computer clock appears to be incorrect."); * break; * case "SyncError": * showToast("Sync failed. Retrying..."); diff --git a/packages/common/src/Sqlite.ts b/packages/common/src/Sqlite.ts index 531861242..2a80ff41d 100644 --- a/packages/common/src/Sqlite.ts +++ b/packages/common/src/Sqlite.ts @@ -8,10 +8,8 @@ import type { Brand } from "./Brand.js"; import type { EncryptionKey } from "./Crypto.js"; import type { Eq } from "./Eq.js"; import { eqArrayNumber } from "./Eq.js"; -import type { UnknownError } from "./Error.js"; -import { createUnknownError } from "./Error.js"; import type { Result } from "./Result.js"; -import { err, ok, trySync } from "./Result.js"; +import { ok } from "./Result.js"; import type { Task } from "./Task.js"; import type { Name, Typed } from "./Type.js"; import { Null, Number, String, Uint8Array, union } from "./Type.js"; @@ -26,25 +24,32 @@ import { Null, Number, String, Uint8Array, union } from "./Type.js"; export interface Sqlite extends Disposable { readonly exec: ( query: SqliteQuery, - ) => Result, SqliteError>; + ) => SqliteExecResult; /** * Executes a transaction, running the provided callback within a begin/commit - * block. If the callback returns an error, the transaction is rolled back. If - * the rollback fails, a {@link SqliteError} is returned with both the original - * error and rollbackError. + * block. + * + * If the callback returns a {@link Result} error, the transaction is rolled + * back and the error result is returned. + * + * If the callback returns `void`, the transaction is committed unless the + * callback throws. */ - readonly transaction: ( - callback: () => Result, - ) => Result; + readonly transaction: SqliteTransaction; - readonly export: () => Result; + readonly export: () => Uint8Array; } export interface SqliteDep { readonly sqlite: Sqlite; } +export interface SqliteTransaction { + (callback: () => Result): Result; + (callback: () => void): void; +} + /** Represents a SQL query to be executed on a {@link Sqlite} database. */ export interface SqliteQuery { readonly sql: SafeSql; @@ -106,12 +111,6 @@ export interface SqliteExecResult { readonly changes: number; } -/** Represents an error that occurred during a SQLite operation. */ -export interface SqliteError extends Typed<"SqliteError"> { - readonly error: UnknownError; - readonly rollbackError?: UnknownError; -} - /** * A row returned from a {@link Sqlite} query, mapping column names to * {@link SqliteValue}. @@ -158,7 +157,7 @@ export const createSqlite = ( name: Name, options?: SqliteDriverOptions, - ): Task => + ): Task => async (run) => { const { createSqliteDriver } = run.deps; const console = run.deps.console.child("sql"); @@ -170,100 +169,59 @@ export const createSqlite = let isDisposed = false; - const rollback = ( - result: Result, - error: UnknownError, - ): Result => { - const rollbackResult = trySync(() => { - console.debug("rollback"); - driver.exec(sql`rollback;`); - }, createSqliteError); - if (!rollbackResult.ok) { - console.warn("rollback failed", rollbackResult.error); - return err({ - type: "SqliteError", - error, - rollbackError: rollbackResult.error.error, - }); - } - return result; - }; - return ok({ - exec: (query) => - trySync( - () => { - console.debug({ query }); - - const label = - query.options?.logQueryExecutionTime && - `SqliteQueryExecutionTime ${query.sql}`; - - if (label) console.time(label); - const result = driver.exec(query); - if (label) console.timeEnd(label); - - if (query.options?.logExplainQueryPlan) { - const result = driver.exec({ - ...query, - sql: `EXPLAIN QUERY PLAN ${query.sql}` as SafeSql, - }); - console.log("[logExplainQueryPlan]", query); - console.log( - drawSqliteQueryPlan( - result.rows as unknown as Array, - ), - ); - } - - console.debug({ result }); - return result as never; - }, - (error): SqliteError => ({ - type: "SqliteError", - error: createUnknownError(error), - }), - ), - - transaction: (callback) => { - const beginResult = trySync(() => { - console.debug("begin"); - driver.exec(sql`begin;`); - }, createSqliteError); - if (!beginResult.ok) return beginResult; - - const callbackResult = trySync(callback, createSqliteError); - if (!callbackResult.ok) { - return rollback(callbackResult, callbackResult.error.error); - } - - const callbackValue = callbackResult.value; - if (!callbackValue.ok) { - return rollback( - callbackValue, - createUnknownError(callbackValue.error), + exec: (query: SqliteQuery) => { + console.debug({ query }); + + const label = + query.options?.logQueryExecutionTime && + `SqliteQueryExecutionTime ${query.sql}`; + + if (label) console.time(label); + const result = driver.exec(query); + if (label) console.timeEnd(label); + + if (query.options?.logExplainQueryPlan) { + const result = driver.exec({ + ...query, + sql: `EXPLAIN QUERY PLAN ${query.sql}` as SafeSql, + }); + console.log("[logExplainQueryPlan]", query); + console.log( + drawSqliteQueryPlan( + result.rows as unknown as Array, + ), ); } - const commitResult = trySync(() => { - console.debug("commit"); - driver.exec(sql`commit;`); - }, createSqliteError); - if (!commitResult.ok) { - return rollback(commitResult, commitResult.error.error); - } - - return ok(callbackValue.value); + console.debug({ result }); + return result as SqliteExecResult; }, - export: () => - trySync( - () => driver.export(), - (error): SqliteError => ({ - type: "SqliteError", - error: createUnknownError(error), - }), - ), + transaction: ((callback: () => Result | void) => { + console.debug("begin"); + driver.exec(sql`begin;`); + + let shouldRollback = true; + using _rollback = { + [Symbol.dispose]: () => { + if (!shouldRollback) return; + console.debug("rollback"); + driver.exec(sql`rollback;`); + }, + }; + + const result = callback(); + if (result != null && !result.ok) return result; + + console.debug("commit"); + driver.exec(sql`commit;`); + shouldRollback = false; + + return result; + }) as SqliteTransaction, + + export: () => driver.export(), [Symbol.dispose]: () => { if (isDisposed) return; @@ -273,11 +231,6 @@ export const createSqlite = }); }; -const createSqliteError = (error: unknown): SqliteError => ({ - type: "SqliteError", - error: createUnknownError(error), -}); - interface SqliteQueryPlanRow { id: number; parent: number; diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index a585371e4..d890799e4 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -9,13 +9,8 @@ import { firstInArray } from "../Array.js"; import { assertNonEmptyReadonlyArray } from "../Assert.js"; import { EncryptionKey, type RandomBytesDep } from "../Crypto.js"; import type { LeaderLockDep } from "../Platform.js"; -import type { Result } from "../Result.js"; import { ok } from "../Result.js"; -import type { - CreateSqliteDriverDep, - SqliteDep, - SqliteError, -} from "../Sqlite.js"; +import type { CreateSqliteDriverDep, SqliteDep } from "../Sqlite.js"; import { createSqlite, sql } from "../Sqlite.js"; import type { AsyncDisposableStack, Task } from "../Task.js"; import type { Name } from "../Type.js"; @@ -140,18 +135,13 @@ const startDbWorker = const result = sqlite.value.transaction(() => { const dbSchema = getDbSchema(deps)(); - if (!dbSchema.ok) return dbSchema; - - const dbIsInitialized = "evolu_version" in dbSchema.value.tables; - const clockResult = createClock(deps)(dbIsInitialized); - if (!clockResult.ok) return clockResult; + const dbIsInitialized = "evolu_version" in dbSchema.tables; - const clock = clockResult.value; + const clock = createClock(deps)(dbIsInitialized); if (!dbIsInitialized) { - const result = initializeDb(deps)(clock.get()); - if (!result.ok) return result; + initializeDb(deps)(clock.get()); } return ok(); @@ -177,7 +167,7 @@ const startDbWorker = const initializeDb = ({ sqlite }: SqliteDep) => - (initialClock: Timestamp): Result => { + (initialClock: Timestamp): void => { for (const query of [ sql` create table evolu_version ( @@ -240,16 +230,15 @@ const initializeDb = ); `, ]) { - const queryResult = sqlite.exec(query); - if (!queryResult.ok) return queryResult; + sqlite.exec(query); } - return createBaseSqliteStorageTables({ sqlite }); + createBaseSqliteStorageTables({ sqlite }); }; interface Clock { readonly get: () => Timestamp; - readonly save: (timestamp: Timestamp) => Result; + readonly save: (timestamp: Timestamp) => void; } // interface ClockDep { @@ -258,7 +247,7 @@ interface Clock { const createClock = (deps: RandomBytesDep & SqliteDep) => - (dbIsInitialized: boolean): Result => { + (dbIsInitialized: boolean): Clock => { let currentTimestamp: Timestamp; if (dbIsInitialized) { @@ -267,30 +256,25 @@ const createClock = from evolu_config limit 1; `); - if (!configResult.ok) return configResult; - - assertNonEmptyReadonlyArray(configResult.value.rows); - const config = firstInArray(configResult.value.rows); + assertNonEmptyReadonlyArray(configResult.rows); + const config = firstInArray(configResult.rows); currentTimestamp = timestampBytesToTimestamp(config.clock); } else { currentTimestamp = createInitialTimestamp(deps); } - return ok({ + return { get: () => currentTimestamp, save: (timestamp) => { currentTimestamp = timestamp; - const result = deps.sqlite.exec(sql.prepared` + deps.sqlite.exec(sql.prepared` update evolu_config set "clock" = ${timestampToTimestampBytes(timestamp)}; `); - if (!result.ok) return result; - - return ok(); }, - }); + }; }; // import { diff --git a/packages/common/src/local-first/Error.ts b/packages/common/src/local-first/Error.ts index b3bf7429b..2efc7aa90 100644 --- a/packages/common/src/local-first/Error.ts +++ b/packages/common/src/local-first/Error.ts @@ -6,7 +6,6 @@ import type { DecryptWithXChaCha20Poly1305Error } from "../Crypto.js"; import type { UnknownError } from "../Error.js"; -import type { SqliteError } from "../Sqlite.js"; import type { ProtocolError } from "./Protocol.js"; import type { TimestampError } from "./Timestamp.js"; @@ -14,6 +13,5 @@ import type { TimestampError } from "./Timestamp.js"; export type EvoluError = | DecryptWithXChaCha20Poly1305Error | ProtocolError - | SqliteError | TimestampError | UnknownError; diff --git a/packages/common/src/local-first/Protocol.ts b/packages/common/src/local-first/Protocol.ts index 1dcd2cc63..e77ef1353 100644 --- a/packages/common/src/local-first/Protocol.ts +++ b/packages/common/src/local-first/Protocol.ts @@ -181,6 +181,7 @@ import { Packr } from "msgpackr"; import { isNonEmptyArray, type NonEmptyReadonlyArray } from "../Array.js"; import { assert } from "../Assert.js"; import type { Brand } from "../Brand.js"; +import type { ConsoleDep } from "../Console.js"; import { type Buffer, bytesToHex, @@ -517,7 +518,7 @@ export const createProtocolMessageFromCrdtMessages = /** Creates a {@link ProtocolMessage} for sync. */ export const createProtocolMessageForSync = - (deps: StorageDep) => + (deps: StorageDep & ConsoleDep) => ( ownerId: OwnerId, subscriptionFlag?: SubscriptionFlag, @@ -529,8 +530,6 @@ export const createProtocolMessageForSync = const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId); const size = deps.storage.getSize(ownerIdBytes); - // Errors are handled by the storage. - if (size == null) return null; splitRange(deps)( ownerIdBytes, @@ -982,9 +981,14 @@ export const applyProtocolMessageAsClient = const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId); if (isNonEmptyArray(messages)) { - const result = await run(storage.writeMessages(ownerIdBytes, messages)); - // Errors are handled by the Storage. Here we just stop syncing. - if (!result.ok) return ok({ type: "NoResponse" }); + try { + const result = await run(storage.writeMessages(ownerIdBytes, messages)); + // Quota errors are handled by Storage; protocol just stops syncing. + if (!result.ok) return ok({ type: "NoResponse" }); + } catch (error) { + run.deps.console.error(error); + return ok({ type: "NoResponse" }); + } } // Now: No writeKey, no sync. @@ -1135,16 +1139,21 @@ export const applyProtocolMessageAsRelay = }); } - const result = await run(storage.writeMessages(ownerIdBytes, messages)); + try { + const result = await run(storage.writeMessages(ownerIdBytes, messages)); - if (!result.ok) { - const errorCode = - result.error.type === "StorageWriteError" - ? ProtocolErrorCode.WriteError - : ProtocolErrorCode.QuotaError; + if (!result.ok) { + const message = createProtocolMessageBuffer(ownerId, { + messageType: MessageType.Response, + errorCode: ProtocolErrorCode.QuotaError, + }).unwrap(); + return ok({ type: "Response", message }); + } + } catch (error) { + run.deps.console.error(error); const message = createProtocolMessageBuffer(ownerId, { messageType: MessageType.Response, - errorCode, + errorCode: ProtocolErrorCode.WriteError, }).unwrap(); return ok({ type: "Response", message }); } @@ -1255,16 +1264,20 @@ const decodeMessages = ( }; const sync = - (deps: StorageDep) => + (deps: StorageDep & ConsoleDep) => ( ranges: NonEmptyReadonlyArray, output: ProtocolMessageBuffer, ownerIdBytes: OwnerIdBytes, ): Result => { const outputInitialSize = output.getSize(); - - const storageSize = deps.storage.getSize(ownerIdBytes); - if (storageSize == null) return err(ProtocolErrorCode.SyncError); + let storageSize: NonNegativeInt; + try { + storageSize = deps.storage.getSize(ownerIdBytes); + } catch (error) { + deps.console.error(error); + return err(ProtocolErrorCode.SyncError); + } let prevUpperBound: RangeUpperBound | null = null; let prevIndex = NonNegativeInt.orThrow(0); @@ -1304,12 +1317,17 @@ const sync = const addFingerprintForRemainingRange = ( begin: NonNegativeInt, ): boolean => { - const fingerprint = deps.storage.fingerprint( - ownerIdBytes, - begin, - storageSize, - ); - if (!fingerprint) return false; + let fingerprint: Fingerprint; + try { + fingerprint = deps.storage.fingerprint( + ownerIdBytes, + begin, + storageSize, + ); + } catch (error) { + deps.console.error(error); + return false; + } // There is always a space for a ramaining range. output.addRange({ type: RangeType.Fingerprint, @@ -1323,13 +1341,18 @@ const sync = const currentUpperBound = range.upperBound; const lower = prevIndex; - let upper = deps.storage.findLowerBound( - ownerIdBytes, - prevIndex, - storageSize, - currentUpperBound, - ); - if (upper == null) return err(ProtocolErrorCode.SyncError); + let upper: NonNegativeInt; + try { + upper = deps.storage.findLowerBound( + ownerIdBytes, + prevIndex, + storageSize, + currentUpperBound, + ); + } catch (error) { + deps.console.error(error); + return err(ProtocolErrorCode.SyncError); + } switch (range.type) { case RangeType.Skip: { @@ -1338,12 +1361,17 @@ const sync = } case RangeType.Fingerprint: { - const ourFingerprint = deps.storage.fingerprint( - ownerIdBytes, - lower, - upper, - ); - if (ourFingerprint == null) return err(ProtocolErrorCode.SyncError); + let ourFingerprint: Fingerprint; + try { + ourFingerprint = deps.storage.fingerprint( + ownerIdBytes, + lower, + upper, + ); + } catch (error) { + deps.console.error(error); + return err(ProtocolErrorCode.SyncError); + } if (eqArrayNumber(range.fingerprint, ourFingerprint)) { skipRange(range); @@ -1374,52 +1402,61 @@ const sync = ); const ourTimestamps = createTimestampsBuffer(); - let cantReadDbChange = false as boolean; let exceeded = false as boolean; + let iterateFailed = false as boolean; + + try { + deps.storage.iterate( + ownerIdBytes, + lower, + upper, + (timestamp, index) => { + const timestampString = timestamp.join(); + const timestampBinary = timestampBytesToTimestamp(timestamp); + + let message: EncryptedCrdtMessage | null = null; + + if (timestampsWeNeed.has(timestampString)) { + timestampsWeNeed.delete(timestampString); + } else { + try { + message = { + timestamp: timestampBinary, + change: deps.storage.readDbChange( + ownerIdBytes, + timestamp, + ), + }; + } catch (error) { + deps.console.error(error); + iterateFailed = true; + return false; + } + } - deps.storage.iterate( - ownerIdBytes, - lower, - upper, - (timestamp, index) => { - const timestampString = timestamp.join(); - const timestampBinary = timestampBytesToTimestamp(timestamp); - - let message: EncryptedCrdtMessage | null = null; - - if (timestampsWeNeed.has(timestampString)) { - timestampsWeNeed.delete(timestampString); - } else { - const dbChange = deps.storage.readDbChange( - ownerIdBytes, - timestamp, - ); - if (dbChange == null) { - cantReadDbChange = true; + if ( + !output.canAddTimestampsRangeAndMessage( + ourTimestamps, + message, + ) + ) { + exceeded = true; + endBound = timestamp; + upper = index; return false; } - message = { - timestamp: timestampBinary, - change: dbChange, - }; - } - - if ( - !output.canAddTimestampsRangeAndMessage(ourTimestamps, message) - ) { - exceeded = true; - endBound = timestamp; - upper = index; - return false; - } - - ourTimestamps.add(timestampBinary); - if (message) output.addMessage(message); - return true; - }, - ); - if (cantReadDbChange) { + ourTimestamps.add(timestampBinary); + if (message) output.addMessage(message); + return true; + }, + ); + } catch (error) { + deps.console.error(error); + return err(ProtocolErrorCode.SyncError); + } + + if (iterateFailed) { return err(ProtocolErrorCode.SyncError); } @@ -1462,7 +1499,7 @@ const sync = }; const splitRange = - (deps: StorageDep) => + (deps: StorageDep & ConsoleDep) => ( ownerId: OwnerIdBytes, lower: NonNegativeInt, @@ -1503,13 +1540,17 @@ const splitRange = ...buckets.value.map((b) => NonNegativeInt.orThrow(b + lower)), ]; - const fingerprintRanges = deps.storage.fingerprintRanges( - ownerId, - fingerprintRangesBuckets, - upperBound, - ); - // Errors are handled by the storage. - if (fingerprintRanges == null) return; + let fingerprintRanges: ReadonlyArray; + try { + fingerprintRanges = deps.storage.fingerprintRanges( + ownerId, + fingerprintRangesBuckets, + upperBound, + ); + } catch (error) { + deps.console.error(error); + return; + } const rangesToUse = lower > 0 ? fingerprintRanges.slice(1) : fingerprintRanges; diff --git a/packages/common/src/local-first/Query.ts b/packages/common/src/local-first/Query.ts index 19022082b..494075bfe 100644 --- a/packages/common/src/local-first/Query.ts +++ b/packages/common/src/local-first/Query.ts @@ -10,12 +10,9 @@ import { createRandomBytes } from "../Crypto.js"; import type { Listener, Unsubscribe } from "../Listeners.js"; import type { ReadonlyRecord } from "../Object.js"; import { createRecord, isPlainObject, objectToEntries } from "../Object.js"; -import type { Result } from "../Result.js"; -import { ok } from "../Result.js"; import type { SafeSql, SqliteDep, - SqliteError, SqliteQuery, SqliteQueryOptions, SqliteRow, @@ -195,18 +192,14 @@ export const createGetQueryRowsCache = (): GetQueryRowsCache => { export const loadQueries = (deps: GetQueryRowsCacheDep & SqliteDep) => - ( - tabId: Id, - queries: ReadonlyArray, - ): Result, SqliteError> => { + (tabId: Id, queries: ReadonlyArray): ReadonlyArray => { const queriesRows = []; for (const query of queries) { const sqlQuery = deserializeQuery(query); const result = deps.sqlite.exec(sqlQuery); - if (!result.ok) return result; - queriesRows.push([query, result.value.rows] as const); + queriesRows.push([query, result.rows] as const); } const queryRowsCache = deps.getQueryRowsCache(tabId); @@ -225,7 +218,7 @@ export const loadQueries = ), }), ); - return ok(queryPatchesArray); + return queryPatchesArray; }; export interface QueryPatches { diff --git a/packages/common/src/local-first/Relay.ts b/packages/common/src/local-first/Relay.ts index dc5f35609..31ae54666 100644 --- a/packages/common/src/local-first/Relay.ts +++ b/packages/common/src/local-first/Relay.ts @@ -11,10 +11,11 @@ import { mapArray, } from "../Array.js"; import type { TimingSafeEqualDep } from "../Crypto.js"; +import { assert } from "../Assert.js"; import { createInstances } from "../Instances.js"; import type { Result } from "../Result.js"; import { err, ok } from "../Result.js"; -import type { SqliteDep, SqliteError } from "../Sqlite.js"; +import type { SqliteDep } from "../Sqlite.js"; import { sql } from "../Sqlite.js"; import type { Mutex } from "../Task.js"; import { createMutex } from "../Task.js"; @@ -27,7 +28,6 @@ import { OwnerWriteKey, } from "./Owner.js"; import type { - CreateBaseSqliteStorageConfig, EncryptedDbChange, SqliteStorageDeps, Storage, @@ -110,8 +110,8 @@ export interface Relay extends AsyncDisposable {} export const createRelaySqliteStorage = (deps: SqliteStorageDeps & TimingSafeEqualDep) => - (config: CreateBaseSqliteStorageConfig): Storage => { - const sqliteStorageBase = createBaseSqliteStorage(deps)(config); + (config: StorageConfig): Storage => { + const sqliteStorageBase = createBaseSqliteStorage(deps)(); /** Mutex instances cached per OwnerId to prevent concurrent writes. */ const ownerMutexes = createInstances(); @@ -136,42 +136,27 @@ export const createRelaySqliteStorage = where ownerId = ${ownerId}; `, ); - if (!selectWriteKey.ok) { - config.onStorageError(selectWriteKey.error); - return false; - } - - const { rows } = selectWriteKey.value; + const { rows } = selectWriteKey; if (isNonEmptyArray(rows)) { return deps.timingSafeEqual(rows[0].writeKey, writeKey); } - const insertWriteKey = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` insert into evolu_writeKey (ownerId, writeKey) values (${ownerId}, ${writeKey}); `); - if (!insertWriteKey.ok) { - config.onStorageError(insertWriteKey.error); - return false; - } return true; }, setWriteKey: (ownerId, writeKey) => { - const upsertWriteKey = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` insert into evolu_writeKey (ownerId, writeKey) values (${ownerId}, ${writeKey}) on conflict (ownerId) do update set writeKey = excluded.writeKey; `); - if (!upsertWriteKey.ok) { - config.onStorageError(upsertWriteKey.error); - return false; - } - - return true; }, writeMessages: (ownerIdBytes, messages) => async (run) => { @@ -184,109 +169,98 @@ export const createRelaySqliteStorage = const result = await run( ownerMutexes .ensure(ownerId, createMutex) - .withLock( - async (): Promise< - Result - > => { - const existingTimestampsResult = - sqliteStorageBase.getExistingTimestamps( - ownerIdBytes, - mapArray(messagesWithTimestampBytes, (m) => m.timestamp), - ); - if (!existingTimestampsResult.ok) - return existingTimestampsResult; - - const existingTimestampsSet = new Set( - existingTimestampsResult.value.map((t) => t.toString()), - ); - const newMessages = filterArray( - messagesWithTimestampBytes, - (m) => !existingTimestampsSet.has(m.timestamp.toString()), - ); - - // Nothing to write - if (!isNonEmptyArray(newMessages)) { - return ok(); - } - - const usage = getOwnerUsage(deps)( + .withLock(async (): Promise> => { + const existingTimestampsResult = + sqliteStorageBase.getExistingTimestamps( ownerIdBytes, - firstInArray(newMessages).timestamp, + mapArray(messagesWithTimestampBytes, (m) => m.timestamp), ); - if (!usage.ok) return usage; - const { storedBytes } = usage.value; + const existingTimestampsSet = new Set( + existingTimestampsResult.map((t) => t.toString()), + ); + const newMessages = filterArray( + messagesWithTimestampBytes, + (m) => !existingTimestampsSet.has(m.timestamp.toString()), + ); + + // Nothing to write + if (!isNonEmptyArray(newMessages)) { + return ok(); + } + + const usage = getOwnerUsage(deps)( + ownerIdBytes, + firstInArray(newMessages).timestamp, + ); + if (!usage.ok) return usage; + + const { storedBytes } = usage.value; + + const incomingBytes = newMessages.reduce( + (sum, m) => sum + m.change.length, + 0, + ); + const newStoredBytes = PositiveInt.orThrow( + (storedBytes ?? 0) + incomingBytes, + ); + + const quotaResult = config.isOwnerWithinQuota( + ownerId, + newStoredBytes, + ); + const isWithinQuota = isPromiseLike(quotaResult) + ? await quotaResult + : quotaResult; + if (!isWithinQuota) { + return err({ type: "StorageQuotaError", ownerId }); + } + + let { firstTimestamp, lastTimestamp } = usage.value; + + return deps.sqlite.transaction(() => { + for (const { timestamp, change } of newMessages) { + let strategy; + [strategy, firstTimestamp, lastTimestamp] = + getTimestampInsertStrategy( + timestamp, + firstTimestamp, + lastTimestamp, + ); + + { + sqliteStorageBase.insertTimestamp( + ownerIdBytes, + timestamp, + strategy, + ); + } - const incomingBytes = newMessages.reduce( - (sum, m) => sum + m.change.length, - 0, - ); - const newStoredBytes = PositiveInt.orThrow( - (storedBytes ?? 0) + incomingBytes, - ); + { + deps.sqlite.exec(sql` + insert into evolu_message + ("ownerId", "timestamp", "change") + values (${ownerIdBytes}, ${timestamp}, ${change}) + on conflict do nothing; + `); + } + } - const quotaResult = config.isOwnerWithinQuota( - ownerId, + updateOwnerUsage(deps)( + ownerIdBytes, newStoredBytes, + firstTimestamp, + lastTimestamp, ); - const isWithinQuota = isPromiseLike(quotaResult) - ? await quotaResult - : quotaResult; - if (!isWithinQuota) { - return err({ type: "StorageQuotaError", ownerId }); - } - let { firstTimestamp, lastTimestamp } = usage.value; - - return deps.sqlite.transaction(() => { - for (const { timestamp, change } of newMessages) { - let strategy; - [strategy, firstTimestamp, lastTimestamp] = - getTimestampInsertStrategy( - timestamp, - firstTimestamp, - lastTimestamp, - ); - - { - const result = sqliteStorageBase.insertTimestamp( - ownerIdBytes, - timestamp, - strategy, - ); - if (!result.ok) return result; - } - - { - const result = deps.sqlite.exec(sql` - insert into evolu_message - ("ownerId", "timestamp", "change") - values (${ownerIdBytes}, ${timestamp}, ${change}) - on conflict do nothing; - `); - if (!result.ok) return result; - } - } - - return updateOwnerUsage(deps)( - ownerIdBytes, - newStoredBytes, - firstTimestamp, - lastTimestamp, - ); - }); - }, - ), + return ok(); + }); + }), ); - if (!result.ok && result.error.type !== "AbortError") { - switch (result.error.type) { - case "SqliteError": - config.onStorageError(result.error); - return err({ type: "StorageWriteError", ownerId }); - case "StorageQuotaError": - return err({ type: "StorageQuotaError", ownerId }); - } + if (!result.ok) { + if (result.error.type === "AbortError") return ok(); + return err({ type: "StorageQuotaError", ownerId }); } return ok(); @@ -300,51 +274,35 @@ export const createRelaySqliteStorage = from evolu_message where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}; `); - if (!result.ok) { - config.onStorageError(result.error); - return null; - } - return result.value.rows[0]?.change; + const row = result.rows[0]; + assert(row, "Every timestamp must have a change"); + return row.change; }, deleteOwner: (ownerId) => { - const transactionResult = deps.sqlite.transaction(() => { - const deleteWriteKey = deps.sqlite.exec(sql` + deps.sqlite.transaction(() => { + deps.sqlite.exec(sql` delete from evolu_writeKey where ownerId = ${ownerId}; `); - if (!deleteWriteKey.ok) return deleteWriteKey; - const deleteMessages = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from evolu_message where ownerId = ${ownerId}; `); - if (!deleteMessages.ok) return deleteMessages; - const deleteUsage = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from evolu_usage where ownerId = ${ownerId}; `); - if (!deleteUsage.ok) return deleteUsage; - const deleteBaseOwner = sqliteStorageBase.deleteOwner(ownerId); - if (!deleteBaseOwner) return err(null); + sqliteStorageBase.deleteOwner(ownerId); return ok(); }); - - if (!transactionResult.ok) { - if (transactionResult.error) - config.onStorageError(transactionResult.error); - return false; - } - - return true; }, }; }; -export const createRelayStorageTables = ( - deps: SqliteDep, -): Result => { +export const createRelayStorageTables = (deps: SqliteDep): void => { for (const query of [ sql` create table evolu_writeKey ( @@ -365,9 +323,6 @@ export const createRelayStorageTables = ( strict; `, ]) { - const result = deps.sqlite.exec(query); - if (!result.ok) return result; + deps.sqlite.exec(query); } - - return ok(); }; diff --git a/packages/common/src/local-first/Schema.ts b/packages/common/src/local-first/Schema.ts index a162c3b89..10b741c64 100644 --- a/packages/common/src/local-first/Schema.ts +++ b/packages/common/src/local-first/Schema.ts @@ -12,13 +12,11 @@ import { mapObject, type ReadonlyRecord, } from "../Object.js"; -import { ok, type Result } from "../Result.js"; import { type SafeSql, sql, SqliteBoolean, type SqliteDep, - type SqliteError, type SqliteQuery, type SqliteQueryOptions, SqliteValue, @@ -448,80 +446,68 @@ export const createQueryBuilder = /** Get the current database schema by reading SQLite metadata. */ export const getDbSchema = (deps: SqliteDep) => - ({ allIndexes = false }: { allIndexes?: boolean } = {}): Result< - DbSchema, - SqliteError - > => { + ({ allIndexes = false }: { allIndexes?: boolean } = {}): DbSchema => { const tables = createRecord>(); - const tableAndColumnInfoRows = deps.sqlite.exec(sql` - select - sqlite_master.name as tableName, - table_info.name as columnName - from - sqlite_master - join pragma_table_info(sqlite_master.name) as table_info; - `); - - if (!tableAndColumnInfoRows.ok) return tableAndColumnInfoRows; - - tableAndColumnInfoRows.value.rows.forEach((row) => { - const { tableName, columnName } = row as unknown as { + const tableAndColumnInfoRows = deps.sqlite.exec<{ tableName: string; columnName: string; - }; - (tables[tableName] ??= new Set()).add(columnName); + }>(sql` + select + sqlite_master.name as tableName, + table_info.name as columnName + from + sqlite_master + join pragma_table_info(sqlite_master.name) as table_info; + `); + + tableAndColumnInfoRows.rows.forEach(({ tableName, columnName }) => { + (tables[tableName] ??= new Set()).add(columnName); + }); + + const indexesRows = deps.sqlite.exec<{ name: string; sql: string | null }>( + allIndexes + ? sql` + select name, sql + from sqlite_master + where type = 'index' and name not like 'sqlite_%'; + ` + : sql` + select name, sql + from sqlite_master + where + type = 'index' + and name not like 'sqlite_%' + and name not like 'evolu_%'; + `, + ); + + const indexes = indexesRows.rows.flatMap((row): Array => { + if (row.sql == null) return []; + return [ + { + name: row.name, + /** + * SQLite returns "CREATE INDEX" for "create index" for some reason. + * Other keywords remain unchanged. We have to normalize the casing for + * {@link indexesAreEqual} manually. + */ + sql: row.sql + .replace("CREATE INDEX", "create index") + .replace("CREATE UNIQUE INDEX", "create unique index"), + }, + ]; }); - const indexesRows = deps.sqlite.exec( - allIndexes - ? sql` - select name, sql - from sqlite_master - where type = 'index' and name not like 'sqlite_%'; - ` - : sql` - select name, sql - from sqlite_master - where - type = 'index' - and name not like 'sqlite_%' - and name not like 'evolu_%'; - `, - ); - - if (!indexesRows.ok) return indexesRows; - - const indexes = indexesRows.value.rows.map( - (row): DbIndex => ({ - name: row.name as string, - /** - * SQLite returns "CREATE INDEX" for "create index" for some reason. - * Other keywords remain unchanged. We have to normalize the casing for - * {@link indexesAreEqual} manually. - */ - sql: (row.sql as string) - .replace("CREATE INDEX", "create index") - .replace("CREATE UNIQUE INDEX", "create unique index"), - }), - ); - - return ok({ tables, indexes }); + return { tables, indexes }; }; export const ensureDbSchema = (deps: SqliteDep) => - ( - newSchema: DbSchema, - currentSchema?: DbSchema, - ): Result => { + (newSchema: DbSchema, currentSchema?: DbSchema): void => { const queries: Array = []; - if (!currentSchema) { - const dbSchema = getDbSchema(deps)(); - if (!dbSchema.ok) return dbSchema; - currentSchema = dbSchema.value; - } + currentSchema ??= getDbSchema(deps)(); for (const [tableName, newColumns] of Object.entries(newSchema.tables)) { const currentColumns = getProperty(currentSchema.tables, tableName); @@ -562,10 +548,8 @@ export const ensureDbSchema = }); for (const query of queries) { - const result = deps.sqlite.exec(query); - if (!result.ok) return result; + deps.sqlite.exec(query); } - return ok(); }; // https://kysely.dev/docs/recipes/splitting-query-building-and-execution diff --git a/packages/common/src/local-first/Storage.ts b/packages/common/src/local-first/Storage.ts index 428bd94bb..928893700 100644 --- a/packages/common/src/local-first/Storage.ts +++ b/packages/common/src/local-first/Storage.ts @@ -14,7 +14,7 @@ import { decrement } from "../Number.js"; import type { RandomDep } from "../Random.js"; import type { Result } from "../Result.js"; import { err, ok } from "../Result.js"; -import type { SqliteDep, SqliteError } from "../Sqlite.js"; +import type { SqliteDep } from "../Sqlite.js"; import { sql, SqliteValue } from "../Sqlite.js"; import type { Task } from "../Task.js"; import type { InferType, Int64String, Typed, TypeError } from "../Type.js"; @@ -89,13 +89,13 @@ export interface StorageConfig { * itself remains synchronous. */ export interface Storage { - readonly getSize: (ownerId: OwnerIdBytes) => NonNegativeInt | null; + readonly getSize: (ownerId: OwnerIdBytes) => NonNegativeInt; readonly fingerprint: ( ownerId: OwnerIdBytes, begin: NonNegativeInt, end: NonNegativeInt, - ) => Fingerprint | null; + ) => Fingerprint; /** * Computes fingerprints with their upper bounds in one call. @@ -108,14 +108,14 @@ export interface Storage { ownerId: OwnerIdBytes, buckets: ReadonlyArray, upperBound?: RangeUpperBound, - ) => ReadonlyArray | null; + ) => ReadonlyArray; readonly findLowerBound: ( ownerId: OwnerIdBytes, begin: NonNegativeInt, end: NonNegativeInt, upperBound: RangeUpperBound, - ) => NonNegativeInt | null; + ) => NonNegativeInt; readonly iterate: ( ownerId: OwnerIdBytes, @@ -138,7 +138,7 @@ export interface Storage { readonly setWriteKey: ( ownerId: OwnerIdBytes, writeKey: OwnerWriteKey, - ) => boolean; + ) => void; /** * Write encrypted {@link CrdtMessage}s to storage. @@ -149,30 +149,22 @@ export interface Storage { readonly writeMessages: ( ownerIdBytes: OwnerIdBytes, messages: NonEmptyReadonlyArray, - ) => Task; + ) => Task; /** Read encrypted {@link DbChange}s from storage. */ readonly readDbChange: ( ownerId: OwnerIdBytes, timestamp: TimestampBytes, - ) => EncryptedDbChange | null; + ) => EncryptedDbChange; - /** - * Delete all data for the given {@link Owner}. - * - * Returns `true` on success, `false` on failure. - */ - readonly deleteOwner: (ownerId: OwnerIdBytes) => boolean; + /** Delete all data for the given {@link Owner}. */ + readonly deleteOwner: (ownerId: OwnerIdBytes) => void; } export interface StorageDep { readonly storage: Storage; } -/** Error indicating a serious write failure. */ -export interface StorageWriteError - extends OwnerError, Typed<"StorageWriteError"> {} - /** Error when storage or billing quota is exceeded. */ export interface StorageQuotaError extends OwnerError, Typed<"StorageQuotaError"> {} @@ -335,7 +327,7 @@ export interface BaseSqliteStorage extends Pick< ownerId: OwnerIdBytes, timestamp: TimestampBytes, strategy: StorageInsertTimestampStrategy, - ) => Result; + ) => void; /** * Efficiently checks which timestamps already exist in the database using a @@ -344,7 +336,7 @@ export interface BaseSqliteStorage extends Pick< readonly getExistingTimestamps: ( ownerIdBytes: OwnerIdBytes, timestampsBytes: NonEmptyReadonlyArray, - ) => Result, SqliteError>; + ) => ReadonlyArray; } export interface BaseSqliteStorageDep { @@ -353,10 +345,6 @@ export interface BaseSqliteStorageDep { export type SqliteStorageDeps = RandomDep & SqliteDep; -export interface CreateBaseSqliteStorageConfig extends StorageConfig { - onStorageError: (error: SqliteError) => void; -} - /** * Creates a {@link BaseSqliteStorage} implementation. * @@ -371,15 +359,14 @@ export interface CreateBaseSqliteStorageConfig extends StorageConfig { * environments yet, the stateless design should work well across them. */ export const createBaseSqliteStorage = - (deps: SqliteStorageDeps) => - (config: CreateBaseSqliteStorageConfig): BaseSqliteStorage => ({ + (deps: SqliteStorageDeps) => (): BaseSqliteStorage => ({ insertTimestamp: ( ownerId: OwnerIdBytes, timestamp: TimestampBytes, strategy: StorageInsertTimestampStrategy, ) => { const level = randomSkiplistLevel(deps); - return insertTimestamp(deps)(ownerId, timestamp, level, strategy); + insertTimestamp(deps)(ownerId, timestamp, level, strategy); }, getExistingTimestamps: (ownerIdBytes, timestampsBytes) => { @@ -407,47 +394,20 @@ export const createBaseSqliteStorage = on t.ownerId = ${ownerIdBytes} and s.timestampBytes = t.t; `); - if (!result.ok) return result; - - return ok(result.value.rows.map((row) => row.timestampBytes)); + return result.rows.map((row) => row.timestampBytes); }, - getSize: (ownerId) => { - const size = getSize(deps)(ownerId); - if (!size.ok) { - config.onStorageError(size.error); - return null; - } - return size.value; - }, + getSize: getSize(deps), fingerprint: (ownerId, begin, end) => { assertBeginEnd(begin, end); - const result = fingerprint(deps)(ownerId, begin, end); - if (!result.ok) { - config.onStorageError(result.error); - return null; - } - return result.value; + return fingerprint(deps)(ownerId, begin, end); }, - fingerprintRanges: (ownerId, buckets, upperBound) => { - const ranges = fingerprintRanges(deps)(ownerId, buckets, upperBound); - if (!ranges.ok) { - config.onStorageError(ranges.error); - return null; - } - return ranges.value; - }, + fingerprintRanges: fingerprintRanges(deps), - findLowerBound: (ownerId, begin, end, upperBound) => { - const lowerBound = findLowerBound(deps)(ownerId, begin, end, upperBound); - if (!lowerBound.ok) { - config.onStorageError(lowerBound.error); - return null; - } - return lowerBound.value; - }, + findLowerBound: (ownerId, begin, end, upperBound) => + findLowerBound(deps)(ownerId, begin, end, upperBound), iterate: (ownerId, begin, end, callback) => { assertBeginEnd(begin, end); @@ -456,12 +416,8 @@ export const createBaseSqliteStorage = // This is much faster than SQL limit with offset. const first = getTimestampByIndex(deps)(ownerId, begin); - if (!first.ok) { - config.onStorageError(first.error); - return; - } - if (!callback(first.value, begin)) return; + if (!callback(first, begin)) return; if (length === 1) return; /** @@ -477,30 +433,21 @@ export const createBaseSqliteStorage = const result = deps.sqlite.exec<{ t: TimestampBytes }>(sql` select t from evolu_timestamp - where ownerId = ${ownerId} and t > ${first.value} + where ownerId = ${ownerId} and t > ${first} order by t limit ${length - 1}; `); - if (!result.ok) { - config.onStorageError(result.error); - return; - } - for (let i = 0; i < result.value.rows.length; i++) { + for (let i = 0; i < result.rows.length; i++) { const index = NonNegativeInt.orThrow(begin + 1 + i); - if (!callback(result.value.rows[i].t, index)) return; + if (!callback(result.rows[i].t, index)) return; } }, deleteOwner: (ownerId) => { - const result = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from evolu_timestamp where ownerId = ${ownerId}; `); - if (!result.ok) { - config.onStorageError(result.error); - return false; - } - return true; }, }); @@ -508,9 +455,7 @@ const assertBeginEnd = (begin: NonNegativeInt, end: NonNegativeInt) => { assert(begin <= end, "invalid begin or end"); }; -export const createBaseSqliteStorageTables = ( - deps: SqliteDep, -): Result => { +export const createBaseSqliteStorageTables = (deps: SqliteDep): void => { for (const query of [ /** * Creates the `evolu_timestamp` table for storing timestamps of multiple @@ -572,10 +517,8 @@ export const createBaseSqliteStorageTables = ( strict; `, ]) { - const result = deps.sqlite.exec(query); - if (!result.ok) return result; + deps.sqlite.exec(query); } - return ok(); }; export type StorageInsertTimestampStrategy = "append" | "prepend" | "insert"; @@ -625,7 +568,7 @@ const insertTimestamp = timestamp: TimestampBytes, level: PositiveInt, strategy: StorageInsertTimestampStrategy, - ): Result => { + ): void => { const [h1, h2] = fingerprintToSqliteFingerprint( timestampBytesToFingerprint(timestamp), ); @@ -1138,11 +1081,8 @@ const insertTimestamp = } for (const query of queries) { - const result = deps.sqlite.exec(query); - if (!result.ok) return result; + deps.sqlite.exec(query); } - - return ok(); }; export const timestampBytesToFingerprint = ( @@ -1232,7 +1172,7 @@ const sqliteFingerprintToFingerprint = ([ const getSize = (deps: SqliteDep) => - (ownerId: OwnerIdBytes): Result => { + (ownerId: OwnerIdBytes): NonNegativeInt => { const result = deps.sqlite.exec<{ size: NonNegativeInt }>(sql.prepared` with ml(ml) as ( @@ -1268,8 +1208,7 @@ const getSize = from sc; `); - if (!result.ok) return result; - return ok(result.value.rows[0].size); + return result.rows[0].size; }; const findLowerBound = @@ -1279,11 +1218,11 @@ const findLowerBound = begin: NonNegativeInt, end: NonNegativeInt, upperBound: RangeUpperBound, - ): Result => { + ): NonNegativeInt => { assertBeginEnd(begin, end); if (end === 0 || begin === end || upperBound === InfiniteUpperBound) { - return ok(end); + return end; } const result = deps.sqlite.exec<{ @@ -1295,25 +1234,19 @@ const findLowerBound = order by t limit 1; `); - if (!result.ok) return result; - if (result.value.rows.length === 0) { - return ok(end); + if (result.rows.length === 0) { + return end; } - const count = getTimestampCount(deps)(ownerId, result.value.rows[0].t); - if (!count.ok) return count; - + const count = getTimestampCount(deps)(ownerId, result.rows[0].t); // `decrement` converts a count to an index. - return ok(NonNegativeInt.orThrow(decrement(count.value))); + return NonNegativeInt.orThrow(decrement(count)); }; const getTimestampCount = (deps: SqliteDep) => - ( - ownerId: OwnerIdBytes, - timestamp: TimestampBytes, - ): Result => { + (ownerId: OwnerIdBytes, timestamp: TimestampBytes): PositiveInt => { const result = deps.sqlite.exec<{ count: PositiveInt; }>(sql.prepared` @@ -1358,8 +1291,7 @@ const getTimestampCount = from sc; `); - if (!result.ok) return result; - return ok(result.value.rows[0].count); + return result.rows[0].count; }; /** @@ -1372,22 +1304,18 @@ const fingerprint = ownerId: OwnerIdBytes, begin: NonNegativeInt, end: NonNegativeInt, - ): Result => { + ): Fingerprint => { // There is no need to fingerprint an empty range. if (end - begin === 0) { - return ok(zeroFingerprint); + return zeroFingerprint; } if (begin === 0) { - const result = fingerprintRanges(deps)(ownerId, [end]); - if (!result.ok) return result; - return ok(result.value[0].fingerprint); + return fingerprintRanges(deps)(ownerId, [end])[0].fingerprint; } // We should have a param to skip the first result. - const result = fingerprintRanges(deps)(ownerId, [begin, end]); - if (!result.ok) return result; - return ok(result.value[1].fingerprint); + return fingerprintRanges(deps)(ownerId, [begin, end])[1].fingerprint; }; /** @@ -1404,7 +1332,7 @@ const fingerprintRanges = ownerId: OwnerIdBytes, buckets: ReadonlyArray, upperBound: RangeUpperBound = InfiniteUpperBound, - ): Result, SqliteError> => { + ): ReadonlyArray => { const bucketsJson = JSON.stringify(buckets); const result = deps.sqlite.exec<{ @@ -1522,9 +1450,7 @@ const fingerprintRanges = from c3; `); - if (!result.ok) return result; - - const fingerprintRanges = result.value.rows.map( + const fingerprintRanges = result.rows.map( (row, i, arr): FingerprintRange => ({ type: RangeType.Fingerprint, upperBound: i === arr.length - 1 ? upperBound : row.b!, @@ -1535,7 +1461,7 @@ const fingerprintRanges = }), ); - return ok(fingerprintRanges); + return fingerprintRanges; }; // XOR in SQLite @@ -1543,10 +1469,7 @@ const x = (a: string, b: string) => sql.raw(`(${a} | ${b}) - (${a} & ${b})`); export const getTimestampByIndex = (deps: SqliteDep) => - ( - ownerId: OwnerIdBytes, - index: NonNegativeInt, - ): Result => { + (ownerId: OwnerIdBytes, index: NonNegativeInt): TimestampBytes => { const result = deps.sqlite.exec<{ readonly pt: TimestampBytes; }>(sql.prepared` @@ -1621,8 +1544,7 @@ export const getTimestampByIndex = where ic == ${index + 1}; `); - if (!result.ok) return result; - return ok(result.value.rows[0].pt); + return result.rows[0].pt; }; /** Retrieves usage information for an owner from the evolu_usage table. */ @@ -1631,14 +1553,11 @@ export const getOwnerUsage = ( ownerIdBytes: OwnerIdBytes, initialTimestamp: TimestampBytes, - ): Result< - { - storedBytes: NonNegativeInt | null; - firstTimestamp: TimestampBytes; - lastTimestamp: TimestampBytes; - }, - SqliteError - > => { + ): Result<{ + storedBytes: NonNegativeInt | null; + firstTimestamp: TimestampBytes; + lastTimestamp: TimestampBytes; + }> => { const result = deps.sqlite.exec<{ storedBytes: NonNegativeInt; firstTimestamp: TimestampBytes | null; @@ -1648,9 +1567,8 @@ export const getOwnerUsage = from evolu_usage where ownerId = ${ownerIdBytes}; `); - if (!result.ok) return result; - if (!isNonEmptyArray(result.value.rows)) { + if (!isNonEmptyArray(result.rows)) { return ok({ storedBytes: null, firstTimestamp: initialTimestamp, @@ -1658,7 +1576,7 @@ export const getOwnerUsage = }); } - const row = firstInArray(result.value.rows); + const row = firstInArray(result.rows); assert(row.firstTimestamp, "not null"); assert(row.lastTimestamp, "not null"); @@ -1682,8 +1600,8 @@ export const updateOwnerUsage = storedBytes: PositiveInt, firstTimestamp: TimestampBytes, lastTimestamp: TimestampBytes, - ): Result => { - const result = deps.sqlite.exec(sql` + ): void => { + deps.sqlite.exec(sql` insert into evolu_usage ("ownerId", "storedBytes", "firstTimestamp", "lastTimestamp") values @@ -1694,6 +1612,4 @@ export const updateOwnerUsage = firstTimestamp = ${firstTimestamp}, lastTimestamp = ${lastTimestamp}; `); - if (!result.ok) return result; - return ok(); }; diff --git a/packages/common/src/local-first/Sync.ts b/packages/common/src/local-first/Sync.ts index d2e7fecd5..3cff5fb48 100644 --- a/packages/common/src/local-first/Sync.ts +++ b/packages/common/src/local-first/Sync.ts @@ -6,7 +6,7 @@ import type { NonEmptyArray, NonEmptyReadonlyArray } from "../Array.js"; import { appendToArray, firstInArray, isNonEmptyArray } from "../Array.js"; -import { assertNonEmptyReadonlyArray } from "../Assert.js"; +import { assert, assertNonEmptyReadonlyArray } from "../Assert.js"; import type { Brand } from "../Brand.js"; import type { ConsoleDep } from "../Console.js"; import type { @@ -14,13 +14,13 @@ import type { RandomBytesDep, } from "../Crypto.js"; import type { UnknownError } from "../Error.js"; -import { lazyFalse, lazyTrue, todo } from "../Function.js"; +import { lazyFalse, lazyVoid, todo } from "../Function.js"; import { createRecord, getProperty, objectToEntries } from "../Object.js"; import type { RandomDep } from "../Random.js"; import { createResources } from "../Resources.js"; import type { Result } from "../Result.js"; -import { err, ok } from "../Result.js"; -import type { SqliteDep, SqliteError } from "../Sqlite.js"; +import { ok } from "../Result.js"; +import type { SqliteDep } from "../Sqlite.js"; import { booleanToSqliteBoolean, sql, @@ -62,12 +62,7 @@ import { } from "./Protocol.js"; import type { DbSchemaDep, MutationChange } from "./Schema.js"; import { systemColumns } from "./Schema.js"; -import type { - BaseSqliteStorage, - CrdtMessage, - Storage, - StorageWriteError, -} from "./Storage.js"; +import type { BaseSqliteStorage, CrdtMessage, Storage } from "./Storage.js"; import { createBaseSqliteStorage, DbChange, @@ -105,7 +100,6 @@ export interface Sync extends Disposable { changes: NonEmptyReadonlyArray, ) => Result< void, - | SqliteError | TimestampCounterOverflowError | TimestampDriftError | TimestampTimeOutOfRangeError @@ -143,7 +137,6 @@ export interface SyncConfig { | ProtocolError | ProtocolInvalidDataError | ProtocolTimestampMismatchError - | SqliteError | DecryptWithXChaCha20Poly1305Error | TimestampCounterOverflowError | TimestampDriftError @@ -167,7 +160,7 @@ export const createSync = TimeDep & TimestampConfigDep, ) => - (config: SyncConfig): Result => { + (config: SyncConfig): Sync => { let isDisposed = false; /** Returns owner data only if actively assigned to at least one transport. */ @@ -176,14 +169,11 @@ export const createSync = return resources.getConsumer(ownerId); }; - const storageResult = createClientStorage({ + const storage = createClientStorage({ ...deps, getSyncOwner, })(config); - if (!storageResult.ok) return storageResult; - const storage = storageResult.value; - const createResource = (transport: OwnerTransport): WebSocket => { const transportKey = createTransportKey(transport); @@ -292,10 +282,10 @@ export const createSync = // The onOpen handler will sync it. if (!webSocket.isOpen()) return; - const message = createProtocolMessageForSync({ storage })( - owner.id, - SubscriptionFlags.Subscribe, - ); + const message = createProtocolMessageForSync({ + storage, + console: deps.console, + })(owner.id, SubscriptionFlags.Subscribe); if (message) webSocket.send(message); }, @@ -361,8 +351,7 @@ export const createSync = } for (const [ownerId, messages] of ownerMessages) { - const result = applyMessages({ ...deps, storage })(ownerId, messages); - if (!result.ok) return result; + applyMessages({ ...deps, storage })(ownerId, messages); const owner = getSyncOwner(ownerId); if (!owner?.writeKey) continue; @@ -392,7 +381,8 @@ export const createSync = } } - return deps.clock.save(clockTimestamp); + deps.clock.save(clockTimestamp); + return ok(); }, [Symbol.dispose]: () => { @@ -402,7 +392,7 @@ export const createSync = }, }; - return ok(sync); + return sync; }; export interface ClockDep { @@ -411,7 +401,7 @@ export interface ClockDep { export interface Clock { readonly get: () => Timestamp; - readonly save: (timestamp: Timestamp) => Result; + readonly save: (timestamp: Timestamp) => void; } export const createClock = @@ -424,13 +414,10 @@ export const createClock = save: (timestamp) => { currentTimestamp = timestamp; - const result = deps.sqlite.exec(sql.prepared` + deps.sqlite.exec(sql.prepared` update evolu_config set "clock" = ${timestampToTimestampBytes(timestamp)}; `); - if (!result.ok) return result; - - return ok(); }, }; }; @@ -461,18 +448,14 @@ const createClientStorage = error: | ProtocolInvalidDataError | ProtocolTimestampMismatchError - | SqliteError | DecryptWithXChaCha20Poly1305Error | TimestampCounterOverflowError | TimestampDriftError | TimestampTimeOutOfRangeError, ) => void; onReceive: () => void; - }): Result => { - const sqliteStorageBase = createBaseSqliteStorage(deps)({ - onStorageError: config.onError, - isOwnerWithinQuota: lazyTrue, // Clients don't have quota limits - }); + }): ClientStorage => { + const sqliteStorageBase = createBaseSqliteStorage(deps)(); // TODO: Mutex per OwnerId like in Relay to support more owners. const mutex = createMutex(); @@ -482,7 +465,7 @@ const createClientStorage = // Not implemented yet. validateWriteKey: lazyFalse, - setWriteKey: lazyFalse, + setWriteKey: lazyVoid, writeMessages: (ownerIdBytes, encryptedMessages) => async (run) => { const ownerId = ownerIdBytesToOwnerId(ownerIdBytes); @@ -493,7 +476,6 @@ const createClientStorage = boolean, | ProtocolInvalidDataError | ProtocolTimestampMismatchError - | SqliteError | DecryptWithXChaCha20Poly1305Error | TimestampCounterOverflowError | TimestampDriftError @@ -539,14 +521,11 @@ const createClientStorage = } if (isNonEmptyArray(messages)) { - const result = applyMessages({ ...deps, storage })( - owner.id, - messages, - ); - if (!result.ok) return result; + applyMessages({ ...deps, storage })(owner.id, messages); } - return deps.clock.save(clockTimestamp); + deps.clock.save(clockTimestamp); + return ok(); }); if (!transaction.ok) return transaction; @@ -559,8 +538,9 @@ const createClientStorage = if (!result.ok) { if (result.error.type !== "AbortError") { config.onError(result.error); + throw new Error(result.error.type, { cause: result.error }); } - return err({ type: "StorageWriteError", ownerId }); + return ok(); } config.onReceive(); @@ -570,8 +550,7 @@ const createClientStorage = readDbChange: (ownerId, timestamp) => { const owner = deps.getSyncOwner(ownerIdBytesToOwnerId(ownerId)); - // Owner can be removed to stop syncing. - if (!owner) return null; + assert(owner, "Sync owner must exist while reading db change"); const result = deps.sqlite.exec<{ readonly table: string; @@ -588,12 +567,7 @@ const createClientStorage = where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}; `); - if (!result.ok) { - config.onError(result.error); - return null; - } - - const { rows } = result.value; + const { rows } = result; assertNonEmptyReadonlyArray(rows, "Every timestamp must have rows"); const firstRow = firstInArray(rows); @@ -634,7 +608,7 @@ const createClientStorage = }, }; - return ok(storage); + return storage; }; type TransportKey = string & Brand<"TransportKey">; @@ -663,45 +637,38 @@ const dbChangeToColumns = (change: DbChange, now: Millis) => { export const applyLocalOnlyChange = (deps: SqliteDep & TimeDep & AppOwnerDep) => - (change: MutationChange): Result => { + (change: MutationChange): void => { if (change.isDelete) { - const result = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from ${sql.identifier(change.table)} where id = ${change.id}; `); - if (!result.ok) return result; } else { const ownerId = deps.appOwner.id; const columns = dbChangeToColumns(change, deps.time.now()); for (const [column, value] of columns) { - const result = deps.sqlite.exec(sql.prepared` + deps.sqlite.exec(sql.prepared` insert into ${sql.identifier(change.table)} ("ownerId", "id", ${sql.identifier(column)}) values (${ownerId}, ${change.id}, ${value}) on conflict ("ownerId", "id") do update set ${sql.identifier(column)} = ${value}; `); - if (!result.ok) return result; } } - - return ok(); }; const applyMessages = (deps: ClientStorageDep & ClockDep & DbSchemaDep & RandomDep & SqliteDep) => - ( - ownerId: OwnerId, - messages: NonEmptyReadonlyArray, - ): Result => { + (ownerId: OwnerId, messages: NonEmptyReadonlyArray): void => { const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId); const usage = getOwnerUsage(deps)( ownerIdBytes, timestampToTimestampBytes(firstInArray(messages).timestamp), ); - if (!usage.ok) return usage; + if (!usage.ok) return; let { firstTimestamp, lastTimestamp } = usage.value; @@ -712,7 +679,7 @@ const applyMessages = for (const [column, value] of columns) { if (validateColumnValue(deps)(change.table, column, value)) { - const result = applyColumnChange(deps)( + applyColumnChange(deps)( ownerIdBytes, ownerId, change.table, @@ -722,9 +689,8 @@ const applyMessages = value, timestampBytes, ); - if (!result.ok) return result; } else { - const result = deps.sqlite.exec(sql.prepared` + deps.sqlite.exec(sql.prepared` insert into evolu_message_quarantine ("ownerId", "timestamp", "table", "id", "column", "value") values @@ -738,7 +704,6 @@ const applyMessages = ) on conflict do nothing; `); - if (!result.ok) return result; } } @@ -749,19 +714,15 @@ const applyMessages = lastTimestamp, ); - const result = deps.storage.insertTimestamp( - ownerIdBytes, - timestampBytes, - strategy, - ); - if (!result.ok) return result; + // TODO: Rethink and maybe refactor. + deps.storage.insertTimestamp(ownerIdBytes, timestampBytes, strategy); } /** * TODO: Implement proper storedBytes tracking for client using received and * sent encrypted message sizes. */ - return updateOwnerUsage(deps)( + updateOwnerUsage(deps)( ownerIdBytes, 1 as PositiveInt, // Placeholder until proper tracking implemented firstTimestamp, @@ -798,8 +759,8 @@ const applyColumnChange = column: string, value: SqliteValue, timestampBytes: TimestampBytes, - ): Result => { - const result = deps.sqlite.exec(sql.prepared` + ): void => { + deps.sqlite.exec(sql.prepared` with existingTimestamp as ( select 1 @@ -820,10 +781,9 @@ const applyColumnChange = set ${sql.identifier(column)} = ${value} where not exists (select 1 from existingTimestamp); `); - if (!result.ok) return result; { - const result = deps.sqlite.exec(sql.prepared` + deps.sqlite.exec(sql.prepared` insert into evolu_history ("ownerId", "table", "id", "column", "value", "timestamp") values @@ -837,10 +797,7 @@ const applyColumnChange = ) on conflict do nothing; `); - if (!result.ok) return result; } - - return ok(); }; /** @@ -849,7 +806,7 @@ const applyColumnChange = * don't exist in the current schema (e.g., from a newer app version). */ export const tryApplyQuarantinedMessages = - (deps: DbSchemaDep & SqliteDep) => (): Result => { + (deps: DbSchemaDep & SqliteDep) => (): void => { const rows = deps.sqlite.exec<{ readonly ownerId: OwnerIdBytes; readonly timestamp: TimestampBytes; @@ -861,13 +818,12 @@ export const tryApplyQuarantinedMessages = select "ownerId", "timestamp", "table", "id", "column", "value" from evolu_message_quarantine; `); - if (!rows.ok) return rows; - for (const row of rows.value.rows) { + for (const row of rows.rows) { if (!validateColumnValue(deps)(row.table, row.column, row.value)) continue; - const result = applyColumnChange(deps)( + applyColumnChange(deps)( row.ownerId, ownerIdBytesToOwnerId(row.ownerId), row.table, @@ -877,10 +833,9 @@ export const tryApplyQuarantinedMessages = row.value, row.timestamp, ); - if (!result.ok) return result; { - const result = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from evolu_message_quarantine where "ownerId" = ${row.ownerId} @@ -889,11 +844,8 @@ export const tryApplyQuarantinedMessages = and "id" = ${row.id} and "column" = ${row.column}; `); - if (!result.ok) return result; } } - - return ok(); }; /** diff --git a/packages/common/test/Sqlite.test.ts b/packages/common/test/Sqlite.test.ts index 9734f954f..64201157d 100644 --- a/packages/common/test/Sqlite.test.ts +++ b/packages/common/test/Sqlite.test.ts @@ -56,13 +56,10 @@ test("basic DDL/DML works", async () => { await using run = await testCreateRunWithSqlite(); const { sqlite } = run.deps; - expect(sqlite.exec(sql`create table a (data);`).ok).toBe(true); - expect(sqlite.exec(sql`insert into a (data) values (${"foo"});`).ok).toBe( - true, - ); + sqlite.exec(sql`create table a (data);`); + sqlite.exec(sql`insert into a (data) values (${"foo"});`); const result = sqlite.exec(sql`select * from a;`); - assert(result.ok); - expect(result.value.rows).toEqual([{ data: "foo" }]); + expect(result.rows).toEqual([{ data: "foo" }]); }); describe("transactions", () => { @@ -72,22 +69,15 @@ describe("transactions", () => { sqlite.exec(sql`create table a (data);`); - const result = sqlite.transaction(() => - sqlite.exec(sql`insert into notexisting (data) values (${"foo"});`), - ); + const action = () => + sqlite.transaction(() => + ok(sqlite.exec(sql`insert into notexisting (data) values (${"foo"});`)), + ); - expect(result).toEqual( - err( - expect.objectContaining({ - type: "SqliteError", - error: expect.anything(), - }), - ), - ); + expect(action).toThrow(); const rows = sqlite.exec(sql`select * from a;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([]); + expect(rows.rows).toEqual([]); const entries = console.getEntriesSnapshot(); const debugLogs = entries.filter((e) => e.method === "debug"); @@ -111,8 +101,7 @@ describe("transactions", () => { } const rows = sqlite.exec(sql`select * from a;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([]); + expect(rows.rows).toEqual([]); const entries = console.getEntriesSnapshot(); const debugLogs = entries.filter((e) => e.method === "debug"); @@ -125,15 +114,15 @@ describe("transactions", () => { sqlite.exec(sql`create table a (data);`); - const result = sqlite.transaction(() => - sqlite.exec(sql`insert into a (data) values (${"bar"});`), - ); + const result = sqlite.transaction(() => { + sqlite.exec(sql`insert into a (data) values (${"bar"});`); + return ok(); + }); expect(result.ok).toBe(true); const rows = sqlite.exec(sql`select * from a;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ data: "bar" }]); + expect(rows.rows).toEqual([{ data: "bar" }]); }); test("transaction callback returns error", async () => { @@ -179,9 +168,8 @@ describe("transactions", () => { assert(sqliteResult.ok); const sqlite = sqliteResult.value; - const result = sqlite.transaction(() => ok("should not reach")); + expect(() => sqlite.transaction(() => ok("should not reach"))).toThrow(); - expect(result.ok).toBe(false); expect(beginCalled).toBe(true); expect(rollbackCalled).toBe(false); }); @@ -192,26 +180,19 @@ describe("transactions", () => { sqlite.exec(sql`create table a (data);`); - const result = sqlite.transaction(() => { - sqlite.exec(sql`insert into a (data) values (${"boom"});`); - throw new Error("Callback failed"); - }); + const action = () => + sqlite.transaction(() => { + sqlite.exec(sql`insert into a (data) values (${"boom"});`); + throw new Error("Callback failed"); + }); - expect(result).toEqual( - err( - expect.objectContaining({ - type: "SqliteError", - error: expect.anything(), - }), - ), - ); + expect(action).toThrow(); const rows = sqlite.exec(sql`select * from a;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([]); + expect(rows.rows).toEqual([]); }); - test("rollback failure logs warning and returns both errors", async () => { + test("rollback failure throws rollback error", async () => { let rollbackCalled = false; const createFailingDriver: CreateSqliteDriver = () => () => { @@ -240,28 +221,11 @@ describe("transactions", () => { const sqliteResult = await run(createSqlite(testName)); assert(sqliteResult.ok); const sqlite = sqliteResult.value; - const { console } = run.deps; - const result = sqlite.transaction(() => - sqlite.exec(sql`select * from users;`), - ); - - expect(result.ok).toBe(false); + expect(() => + sqlite.transaction(() => ok(sqlite.exec(sql`select * from users;`))), + ).toThrow(); expect(rollbackCalled).toBe(true); - - if (!result.ok) { - expect(result.error.type).toBe("SqliteError"); - expect(result.error.rollbackError).toEqual( - expect.objectContaining({ - type: "UnknownError", - error: expect.objectContaining({ message: "Rollback failed" }), - }), - ); - } - - const entries = console.getEntriesSnapshot(); - const warnLogs = entries.filter((e) => e.method === "warn"); - expect(warnLogs.map((e) => e.args[0])).toContain("rollback failed"); }); test("transaction commit failure triggers rollback", async () => { @@ -297,9 +261,7 @@ describe("transactions", () => { assert(sqliteResult.ok); const sqlite = sqliteResult.value; - const result = sqlite.transaction(() => ok("data")); - - expect(result.ok).toBe(false); + expect(() => sqlite.transaction(() => ok("data"))).toThrow(); expect(commitCalled).toBe(true); expect(rollbackCalled).toBe(true); }); @@ -314,12 +276,11 @@ describe("export", () => { sqlite.exec(sql`insert into a (data) values (${"foo"});`); const result = sqlite.export(); - assert(result.ok); - expect(result.value).toBeInstanceOf(Uint8Array); - expect(result.value.length).toBeGreaterThan(0); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBeGreaterThan(0); }); - test("export failure returns SqliteError", async () => { + test("export failure throws", async () => { const createFailingDriver: CreateSqliteDriver = () => () => { const driver: SqliteDriver = { exec: () => ({ rows: [], changes: 0 }), @@ -338,11 +299,7 @@ describe("export", () => { assert(sqliteResult.ok); const sqlite = sqliteResult.value; - const result = sqlite.export(); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.type).toBe("SqliteError"); - } + expect(() => sqlite.export()).toThrow("Export failed"); }); }); @@ -376,7 +333,7 @@ describe("logExplainQueryPlan", () => { }; const result = sqlite.exec(query); - expect(result.ok).toBe(true); + expect(result.rows).toEqual([]); const entries = run.deps.console.getEntriesSnapshot(); const logEntries = entries.filter( diff --git a/packages/common/test/_deps.ts b/packages/common/test/_deps.ts index 56084185e..6835d5dfc 100644 --- a/packages/common/test/_deps.ts +++ b/packages/common/test/_deps.ts @@ -58,9 +58,6 @@ export const testCreateRunWithSqliteAndRelayStorage = async ( ...run.deps, timingSafeEqual: testTimingSafeEqual, })({ - onStorageError: (error) => { - throw new Error(error.type); - }, isOwnerWithinQuota: lazyTrue, ...config, }); diff --git a/packages/common/test/local-first/Protocol.test.ts b/packages/common/test/local-first/Protocol.test.ts index 1bd2d6232..48dc57c9a 100644 --- a/packages/common/test/local-first/Protocol.test.ts +++ b/packages/common/test/local-first/Protocol.test.ts @@ -1062,7 +1062,7 @@ describe("E2E sync", () => { relayStorage: Storage, rangesMaxSize = defaultProtocolMessageRangesMaxSize, ) => { - const clientStorageDep = { storage: clientStorage }; + const clientStorageDep = { storage: clientStorage, console: deps.console }; const relayStorageDep = { storage: relayStorage }; let message = createProtocolMessageForSync(clientStorageDep)( @@ -1113,7 +1113,7 @@ describe("E2E sync", () => { testAppOwnerIdBytes, timestampToTimestampBytes(message.timestamp), ) - ?.join(), + .join(), ).toBe(message.change.join()); expect( @@ -1122,7 +1122,7 @@ describe("E2E sync", () => { testAppOwnerIdBytes, timestampToTimestampBytes(message.timestamp), ) - ?.join(), + .join(), ).toBe(message.change.join()); } diff --git a/packages/common/test/local-first/Relay.test.ts b/packages/common/test/local-first/Relay.test.ts index 12c0e79ef..03d715a43 100644 --- a/packages/common/test/local-first/Relay.test.ts +++ b/packages/common/test/local-first/Relay.test.ts @@ -64,8 +64,7 @@ test("deleteOwner", async () => { expect(storage.getSize(testAppOwnerIdBytes)).toBe(1); - const deleteResult = storage.deleteOwner(testAppOwnerIdBytes); - expect(deleteResult).toBe(true); + storage.deleteOwner(testAppOwnerIdBytes); for (const table of ["evolu_timestamp", "evolu_message", "evolu_writeKey"]) { const countResult = sqlite.exec<{ count: number }>(sql` @@ -73,7 +72,7 @@ test("deleteOwner", async () => { from ${sql.raw(table)} where ownerid = ${testAppOwnerIdBytes}; `); - expect(countResult.ok && countResult.value.rows[0].count).toBe(0); + expect(countResult.rows[0].count).toBe(0); } }); @@ -92,8 +91,7 @@ describe("writeMessages", () => { from evolu_usage where ownerid = ${ownerId}; `); - assert(usageResult.ok); - return usageResult.value.rows[0].storedBytes as NonNegativeInt; + return usageResult.rows[0].storedBytes as NonNegativeInt; }; const message = createTestMessage(); @@ -137,8 +135,7 @@ describe("writeMessages", () => { where ownerid = ${testAppOwnerIdBytes}; `); - assert(countResult.ok); - expect(countResult.value.rows[0].count).toBe(1); + expect(countResult.rows[0].count).toBe(1); }); test("mutex prevents concurrent writes for same owner", async () => { @@ -218,8 +215,7 @@ describe("writeMessages", () => { where ownerid = ${testAppOwnerIdBytes}; `); - assert(messageCountResult.ok); - expect(messageCountResult.value.rows[0].count).toBe(0); + expect(messageCountResult.rows[0].count).toBe(0); const usageResult = sqlite.exec<{ count: number }>(sql` select count(*) as count @@ -227,8 +223,7 @@ describe("writeMessages", () => { where ownerid = ${testAppOwnerIdBytes}; `); - assert(usageResult.ok); - expect(usageResult.value.rows[0].count).toBe(0); + expect(usageResult.rows[0].count).toBe(0); }); describe("isOwnerWithinQuota", () => { diff --git a/packages/common/test/local-first/Schema.test.ts b/packages/common/test/local-first/Schema.test.ts index 1369769c6..eaa174636 100644 --- a/packages/common/test/local-first/Schema.test.ts +++ b/packages/common/test/local-first/Schema.test.ts @@ -14,7 +14,6 @@ import { ensureDbSchema, getDbSchema, } from "../../src/local-first/Schema.js"; -import { ok } from "../../src/Result.js"; import { SqliteBoolean } from "../../src/Sqlite.js"; import { Boolean, @@ -263,21 +262,17 @@ describe("ensureDbSchema", () => { indexes: [], }; - const result = ensureDbSchema(run.deps)(newSchema); - expect(result).toEqual(ok()); + ensureDbSchema(run.deps)(newSchema); const dbSchema = getDbSchema(run.deps)(); - expect(dbSchema.ok).toBe(true); - if (!dbSchema.ok) return; - - expect(dbSchema.value.tables.todo).toBeDefined(); - expect(dbSchema.value.tables.todo.has("id")).toBe(true); - expect(dbSchema.value.tables.todo.has("title")).toBe(true); - expect(dbSchema.value.tables.todo.has("isCompleted")).toBe(true); - expect(dbSchema.value.tables.todo.has("createdAt")).toBe(true); - expect(dbSchema.value.tables.todo.has("updatedAt")).toBe(true); - expect(dbSchema.value.tables.todo.has("isDeleted")).toBe(true); - expect(dbSchema.value.tables.todo.has("ownerId")).toBe(true); + expect(dbSchema.tables.todo).toBeDefined(); + expect(dbSchema.tables.todo.has("id")).toBe(true); + expect(dbSchema.tables.todo.has("title")).toBe(true); + expect(dbSchema.tables.todo.has("isCompleted")).toBe(true); + expect(dbSchema.tables.todo.has("createdAt")).toBe(true); + expect(dbSchema.tables.todo.has("updatedAt")).toBe(true); + expect(dbSchema.tables.todo.has("isDeleted")).toBe(true); + expect(dbSchema.tables.todo.has("ownerId")).toBe(true); }); test("adds new columns to existing tables", async () => { @@ -290,8 +285,7 @@ describe("ensureDbSchema", () => { indexes: [], }; - const result1 = ensureDbSchema(run.deps)(initialSchema); - expect(result1).toEqual(ok()); + ensureDbSchema(run.deps)(initialSchema); const updatedSchema: DbSchema = { tables: { @@ -300,16 +294,12 @@ describe("ensureDbSchema", () => { indexes: [], }; - const result2 = ensureDbSchema(run.deps)(updatedSchema); - expect(result2).toEqual(ok()); + ensureDbSchema(run.deps)(updatedSchema); const dbSchema = getDbSchema(run.deps)(); - expect(dbSchema.ok).toBe(true); - if (!dbSchema.ok) return; - - expect(dbSchema.value.tables.todo.has("title")).toBe(true); - expect(dbSchema.value.tables.todo.has("isCompleted")).toBe(true); - expect(dbSchema.value.tables.todo.has("priority")).toBe(true); + expect(dbSchema.tables.todo.has("title")).toBe(true); + expect(dbSchema.tables.todo.has("isCompleted")).toBe(true); + expect(dbSchema.tables.todo.has("priority")).toBe(true); }); test("creates multiple tables", async () => { @@ -323,17 +313,13 @@ describe("ensureDbSchema", () => { indexes: [], }; - const result = ensureDbSchema(run.deps)(newSchema); - expect(result).toEqual(ok()); + ensureDbSchema(run.deps)(newSchema); const dbSchema = getDbSchema(run.deps)(); - expect(dbSchema.ok).toBe(true); - if (!dbSchema.ok) return; - - expect(dbSchema.value.tables.todo).toBeDefined(); - expect(dbSchema.value.tables.category).toBeDefined(); - expect(dbSchema.value.tables.todo.has("title")).toBe(true); - expect(dbSchema.value.tables.category.has("name")).toBe(true); + expect(dbSchema.tables.todo).toBeDefined(); + expect(dbSchema.tables.category).toBeDefined(); + expect(dbSchema.tables.todo.has("title")).toBe(true); + expect(dbSchema.tables.category.has("name")).toBe(true); }); test("uses set difference to find new columns", async () => { @@ -355,20 +341,16 @@ describe("ensureDbSchema", () => { indexes: [], }; - const result = ensureDbSchema(run.deps)(updatedSchema); - expect(result).toEqual(ok()); + ensureDbSchema(run.deps)(updatedSchema); const dbSchema = getDbSchema(run.deps)(); - expect(dbSchema.ok).toBe(true); - if (!dbSchema.ok) return; - // Original columns still exist - expect(dbSchema.value.tables.todo.has("a")).toBe(true); - expect(dbSchema.value.tables.todo.has("b")).toBe(true); - expect(dbSchema.value.tables.todo.has("c")).toBe(true); + expect(dbSchema.tables.todo.has("a")).toBe(true); + expect(dbSchema.tables.todo.has("b")).toBe(true); + expect(dbSchema.tables.todo.has("c")).toBe(true); // New columns added via difference - expect(dbSchema.value.tables.todo.has("d")).toBe(true); - expect(dbSchema.value.tables.todo.has("e")).toBe(true); + expect(dbSchema.tables.todo.has("d")).toBe(true); + expect(dbSchema.tables.todo.has("e")).toBe(true); }); test("with currentSchema parameter skips getDbSchema call", async () => { @@ -392,13 +374,9 @@ describe("ensureDbSchema", () => { }; // Pass currentSchema to skip getDbSchema - const result = ensureDbSchema(run.deps)(newSchema, currentSchema); - expect(result).toEqual(ok()); + ensureDbSchema(run.deps)(newSchema, currentSchema); const dbSchema = getDbSchema(run.deps)(); - expect(dbSchema.ok).toBe(true); - if (!dbSchema.ok) return; - - expect(dbSchema.value.tables.todo.has("description")).toBe(true); + expect(dbSchema.tables.todo.has("description")).toBe(true); }); }); diff --git a/packages/common/test/local-first/Storage.test.ts b/packages/common/test/local-first/Storage.test.ts index 2b2debd0e..37516ba75 100644 --- a/packages/common/test/local-first/Storage.test.ts +++ b/packages/common/test/local-first/Storage.test.ts @@ -1,6 +1,5 @@ import { sha256 } from "@noble/hashes/sha2.js"; import { assert, expect, test } from "vitest"; -import { lazyTrue } from "../../src/Function.js"; import { ownerIdToOwnerIdBytes } from "../../src/local-first/Owner.js"; import type { BaseSqliteStorageDep, @@ -25,7 +24,7 @@ import { } from "../../src/local-first/Timestamp.js"; import { computeBalancedBuckets } from "../../src/Number.js"; import { createRandom } from "../../src/Random.js"; -import { getOrThrow, ok } from "../../src/Result.js"; +import { ok } from "../../src/Result.js"; import type { SqliteDep } from "../../src/Sqlite.js"; import { sql } from "../../src/Sqlite.js"; import { testCreateDeps } from "../../src/Test.js"; @@ -49,15 +48,9 @@ const createDeps = async (): Promise => { // Pseudo-random does not scale (randomness is limited). const random = createRandom(); - const result = createBaseSqliteStorageTables({ sqlite }); - assert(result.ok); + createBaseSqliteStorageTables({ sqlite }); - const storage = createBaseSqliteStorage({ sqlite, random })({ - onStorageError: (error) => { - throw new Error(error.type); - }, - isOwnerWithinQuota: lazyTrue, // Allow all writes in tests - }); + const storage = createBaseSqliteStorage({ sqlite, random })(); return { sqlite, storage }; }; @@ -136,16 +129,14 @@ const testTimestamps = async ( ? null : (fingerprintRanges[i].upperBound as TimestampBytes); - const { rows: timestampRows } = getOrThrow( - deps.sqlite.exec<{ t: TimestampBytes }>(sql` - select t - from evolu_timestamp - where - (${lower} is null or t >= ${lower}) - and (${upper} is null or t < ${upper}) - and ownerid = ${testAppOwnerIdBytes}; - `), - ); + const { rows: timestampRows } = deps.sqlite.exec<{ t: TimestampBytes }>(sql` + select t + from evolu_timestamp + where + (${lower} is null or t >= ${lower}) + and (${upper} is null or t < ${upper}) + and ownerid = ${testAppOwnerIdBytes}; + `); incrementalCounts.push(timestampRows.length); @@ -217,7 +208,7 @@ test("empty db", async () => { 0 as NonNegativeInt, 0 as NonNegativeInt, ); - expect(fingerprint?.join()).toBe("0,0,0,0,0,0,0,0,0,0,0,0"); + expect(fingerprint.join()).toBe("0,0,0,0,0,0,0,0,0,0,0,0"); const lowerBound = deps.storage.findLowerBound( testAppOwnerIdBytes, @@ -326,8 +317,7 @@ test("getTimestampByIndex", async () => { testAppOwnerIdBytes, i as NonNegativeInt, ); - assert(timestamp.ok); - expect(timestamp.value.join()).toBe(testTimestampsAsc[i].join()); + expect(timestamp.join()).toBe(testTimestampsAsc[i].join()); } }); diff --git a/packages/common/test/local-first/_utils.ts b/packages/common/test/local-first/_utils.ts index 07a78297d..18eeaab31 100644 --- a/packages/common/test/local-first/_utils.ts +++ b/packages/common/test/local-first/_utils.ts @@ -1,10 +1,9 @@ -import { assert } from "../../src/Assert.js"; import { DbSchema, getDbSchema } from "../../src/local-first/Schema.js"; -import { DbChange } from "../../src/local-first/Storage.js"; import type { CrdtMessage } from "../../src/local-first/Storage.js"; +import { DbChange } from "../../src/local-first/Storage.js"; import { createTimestamp } from "../../src/local-first/Timestamp.js"; -import { sql } from "../../src/Sqlite.js"; import type { SqliteDep } from "../../src/Sqlite.js"; +import { sql } from "../../src/Sqlite.js"; import { Millis } from "../../src/Time.js"; import { Id } from "../../src/Type.js"; @@ -18,23 +17,21 @@ export interface DbSnapshot { export const getDbSnapshot = (deps: SqliteDep): DbSnapshot => { const schema = getDbSchema(deps)({ allIndexes: true }); - assert(schema.ok, "bug"); const tables = []; - for (const tableName in schema.value.tables) { + for (const tableName in schema.tables) { const result = deps.sqlite.exec(sql` select * from ${sql.identifier(tableName)}; `); - assert(result.ok, "bug"); tables.push({ name: tableName, - rows: result.value.rows, + rows: result.rows, }); } - return { schema: schema.value, tables }; + return { schema, tables }; }; export const createTestCrdtMessage = ( diff --git a/packages/nodejs/src/local-first/Relay.ts b/packages/nodejs/src/local-first/Relay.ts index 042359c83..ba6a9c370 100644 --- a/packages/nodejs/src/local-first/Relay.ts +++ b/packages/nodejs/src/local-first/Relay.ts @@ -1,5 +1,4 @@ import { - allResult, callback, createRandom, createRelation, @@ -10,7 +9,6 @@ import { OwnerId, type RandomDep, Name, - type SqliteError, type Task, type TimingSafeEqualDep, Uint8Array, @@ -78,25 +76,16 @@ export const startRelay = const dbFileExists = existsSync(`${name}.db`); - const handleError = (error: SqliteError) => { - console.error(error); - return ok(stack); - }; - - const sqlite = await stack.use(createSqlite(name)); - if (!sqlite.ok) return handleError(sqlite.error); - const deps = { ..._run.deps, sqlite: sqlite.value }; + const sqliteResult = await stack.use(createSqlite(name)); + if (!sqliteResult.ok) return sqliteResult; + const deps = { ..._run.deps, sqlite: sqliteResult.value }; if (!dbFileExists) { - const result = allResult([ - createBaseSqliteStorageTables(deps), - createRelayStorageTables(deps), - ]); - if (!result.ok) return handleError(result.error); + createBaseSqliteStorageTables(deps); + createRelayStorageTables(deps); } const storage = createRelaySqliteStorage(deps)({ - onStorageError: console.error, isOwnerWithinQuota, }); diff --git a/packages/nodejs/test/Sqlite.test.ts b/packages/nodejs/test/Sqlite.test.ts index 89f5831ef..88db6d584 100644 --- a/packages/nodejs/test/Sqlite.test.ts +++ b/packages/nodejs/test/Sqlite.test.ts @@ -23,8 +23,7 @@ describe("createBetterSqliteDriver", () => { sqlite.exec(sql`create table t (data text);`); sqlite.exec(sql`insert into t (data) values (${"hello"});`); const rows = sqlite.exec(sql`select * from t;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ data: "hello" }]); + expect(rows.rows).toEqual([{ data: "hello" }]); sqlite[Symbol.dispose](); }); @@ -42,9 +41,8 @@ describe("createBetterSqliteDriver", () => { sqlite.exec(sql`insert into t (name) values (${"Bob"});`); const rows = sqlite.exec(sql`select name from t order by id;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ name: "Alice" }, { name: "Bob" }]); - expect(rows.value.changes).toBe(0); + expect(rows.rows).toEqual([{ name: "Alice" }, { name: "Bob" }]); + expect(rows.changes).toBe(0); sqlite[Symbol.dispose](); }); @@ -62,9 +60,8 @@ describe("createBetterSqliteDriver", () => { sqlite.exec(sql`insert into t (name) values (${"Bob"});`); const deleteResult = sqlite.exec(sql`delete from t;`); - assert(deleteResult.ok); - expect(deleteResult.value.rows).toEqual([]); - expect(deleteResult.value.changes).toBe(2); + expect(deleteResult.rows).toEqual([]); + expect(deleteResult.changes).toBe(2); sqlite[Symbol.dispose](); }); @@ -81,9 +78,8 @@ describe("createBetterSqliteDriver", () => { sqlite.exec(sql`insert into t (data) values (${"foo"});`); const exported = sqlite.export(); - assert(exported.ok); - expect(exported.value).toBeInstanceOf(Uint8Array); - expect(exported.value.length).toBeGreaterThan(0); + expect(exported).toBeInstanceOf(Uint8Array); + expect(exported.length).toBeGreaterThan(0); sqlite[Symbol.dispose](); }); @@ -113,12 +109,11 @@ describe("createBetterSqliteDriver", () => { // Execute the same query twice — both should succeed via cached statement const insert1 = sqlite.exec(sql`insert into t (name) values (${"A"});`); const insert2 = sqlite.exec(sql`insert into t (name) values (${"B"});`); - assert(insert1.ok); - assert(insert2.ok); + expect(insert1.changes).toBe(1); + expect(insert2.changes).toBe(1); const rows = sqlite.exec(sql`select name from t order by id;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ name: "A" }, { name: "B" }]); + expect(rows.rows).toEqual([{ name: "A" }, { name: "B" }]); sqlite[Symbol.dispose](); }); diff --git a/packages/web/test/Sqlite.test.ts b/packages/web/test/Sqlite.test.ts index 682af487b..c3095daf1 100644 --- a/packages/web/test/Sqlite.test.ts +++ b/packages/web/test/Sqlite.test.ts @@ -65,8 +65,7 @@ describe("createWasmSqliteDriver", () => { sqlite.exec(sql`create table t (data text);`); sqlite.exec(sql`insert into t (data) values (${"hello"});`); const rows = sqlite.exec(sql`select * from t;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ data: "hello" }]); + expect(rows.rows).toEqual([{ data: "hello" }]); sqlite[Symbol.dispose](); }); @@ -84,9 +83,8 @@ describe("createWasmSqliteDriver", () => { sqlite.exec(sql`insert into t (name) values (${"Bob"});`); const deleteResult = sqlite.exec(sql`delete from t;`); - assert(deleteResult.ok); - expect(deleteResult.value.rows).toEqual([]); - expect(deleteResult.value.changes).toBe(2); + expect(deleteResult.rows).toEqual([]); + expect(deleteResult.changes).toBe(2); sqlite[Symbol.dispose](); }); @@ -105,8 +103,7 @@ describe("createWasmSqliteDriver", () => { sqlite.exec(sql.prepared`insert into t (name) values (${"B"});`); const rows = sqlite.exec(sql.prepared`select name from t order by id;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ name: "A" }, { name: "B" }]); + expect(rows.rows).toEqual([{ name: "A" }, { name: "B" }]); sqlite[Symbol.dispose](); }); @@ -123,9 +120,8 @@ describe("createWasmSqliteDriver", () => { sqlite.exec(sql`insert into t (data) values (${"foo"});`); const exported = sqlite.export(); - assert(exported.ok); - expect(exported.value).toBeInstanceOf(Uint8Array); - expect(exported.value.length).toBeGreaterThan(0); + expect(exported).toBeInstanceOf(Uint8Array); + expect(exported.length).toBeGreaterThan(0); sqlite[Symbol.dispose](); }); From 1f7be0dce9f1fb28cc3f1c680267a5c709f4e82b Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 22:29:55 +0100 Subject: [PATCH 34/60] Update pnpm-lock.yaml --- pnpm-lock.yaml | 240 +++++++++++++++++++++++++------------------------ 1 file changed, 125 insertions(+), 115 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 312024e66..46200ad45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,7 +30,7 @@ importers: devDependencies: '@biomejs/biome': specifier: ^2.3.14 - version: 2.3.15 + version: 2.4.0 '@changesets/cli': specifier: ^2.29.8 version: 2.29.8(@types/node@24.10.13) @@ -57,7 +57,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-plugin-jsdoc: specifier: ^62.4.1 - version: 62.5.4(eslint@9.39.2(jiti@2.6.1)) + version: 62.5.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: specifier: ^7.0.1 version: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -81,7 +81,7 @@ importers: version: 6.1.2 turbo: specifier: ^2.8.1 - version: 2.8.8 + version: 2.8.9 typedoc: specifier: ^0.28.16 version: 0.28.17(typescript@5.9.3) @@ -415,7 +415,7 @@ importers: version: 0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4) react-native-quick-crypto: specifier: ^1.0.6 - version: 1.0.10(expo@54.0.33)(react-native-nitro-modules@0.31.10(react-native@0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4))(react@19.2.4))(react-native-quick-base64@2.2.2(react-native@0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4))(react@19.2.4))(react-native@0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4))(react@19.2.4) + version: 1.0.11(expo@54.0.33)(react-native-nitro-modules@0.31.10(react-native@0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4))(react@19.2.4))(react-native-quick-base64@2.2.2(react-native@0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4))(react@19.2.4))(react-native@0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4))(react@19.2.4) react-native-safe-area-context: specifier: ^5.6.2 version: 5.6.2(react-native@0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4))(react@19.2.4) @@ -622,16 +622,16 @@ importers: version: link:../../packages/web '@sveltejs/vite-plugin-svelte': specifier: ^6.2.4 - version: 6.2.4(svelte@5.51.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)) + version: 6.2.4(svelte@5.51.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)) '@tsconfig/svelte': specifier: ^5.0.6 version: 5.0.7 svelte: specifier: ^5.48.2 - version: 5.51.0 + version: 5.51.2 svelte-check: specifier: ^4.3.6 - version: 4.4.0(picomatch@4.0.3)(svelte@5.51.0)(typescript@5.9.3) + version: 4.4.0(picomatch@4.0.3)(svelte@5.51.2)(typescript@5.9.3) tslib: specifier: ^2.8.1 version: 2.8.1 @@ -874,16 +874,16 @@ importers: version: link:../web '@sveltejs/package': specifier: ^2.5.7 - version: 2.5.7(svelte@5.51.0)(typescript@5.9.3) + version: 2.5.7(svelte@5.51.2)(typescript@5.9.3) '@tsconfig/svelte': specifier: ^5.0.6 version: 5.0.7 svelte: specifier: ^5.48.2 - version: 5.51.0 + version: 5.51.2 svelte-check: specifier: ^4.3.6 - version: 4.4.0(picomatch@4.0.3)(svelte@5.51.0)(typescript@5.9.3) + version: 4.4.0(picomatch@4.0.3)(svelte@5.51.2)(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1848,59 +1848,59 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@biomejs/biome@2.3.15': - resolution: {integrity: sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g==} + '@biomejs/biome@2.4.0': + resolution: {integrity: sha512-iluT61cORUDIC5i/y42ljyQraCemmmcgbMLLCnYO+yh+2hjTmcMFcwY8G0zTzWCsPb3t3AyKc+0t/VuhPZULUg==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.3.15': - resolution: {integrity: sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw==} + '@biomejs/cli-darwin-arm64@2.4.0': + resolution: {integrity: sha512-L+YpOtPSuU0etomfvFTPWRsa7+8ejaJL3yaROEoT/96HDJbR6OsvZQk0C8JUYou+XFdP+JcGxqZknkp4n934RA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.3.15': - resolution: {integrity: sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw==} + '@biomejs/cli-darwin-x64@2.4.0': + resolution: {integrity: sha512-Aq+S7ffpb5ynTyLgtnEjG+W6xuTd2F7FdC7J6ShpvRhZwJhjzwITGF9vrqoOnw0sv1XWkt2Q1Rpg+hleg/Xg7Q==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.3.15': - resolution: {integrity: sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg==} + '@biomejs/cli-linux-arm64-musl@2.4.0': + resolution: {integrity: sha512-1rhDUq8sf7xX3tg7vbnU3WVfanKCKi40OXc4VleBMzRStmQHdeBY46aFP6VdwEomcVjyNiu+Zcr3LZtAdrZrjQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.3.15': - resolution: {integrity: sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg==} + '@biomejs/cli-linux-arm64@2.4.0': + resolution: {integrity: sha512-u2p54IhvNAWB+h7+rxCZe3reNfQYFK+ppDw+q0yegrGclFYnDPZAntv/PqgUacpC3uxTeuWFgWW7RFe3lHuxOA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.3.15': - resolution: {integrity: sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw==} + '@biomejs/cli-linux-x64-musl@2.4.0': + resolution: {integrity: sha512-Omo0xhl63z47X+CrE5viEWKJhejJyndl577VoXg763U/aoATrK3r5+8DPh02GokWPeODX1Hek00OtjjooGan9w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.3.15': - resolution: {integrity: sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg==} + '@biomejs/cli-linux-x64@2.4.0': + resolution: {integrity: sha512-WVFOhsnzhrbMGOSIcB9yFdRV2oG2KkRRhIZiunI9gJqSU3ax9ErdnTxRfJUxZUI9NbzVxC60OCXNcu+mXfF/Tw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.3.15': - resolution: {integrity: sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA==} + '@biomejs/cli-win32-arm64@2.4.0': + resolution: {integrity: sha512-aqRwW0LJLV1v1NzyLvLWQhdLmDSAV1vUh+OBdYJaa8f28XBn5BZavo+WTfqgEzALZxlNfBmu6NGO6Al3MbCULw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.3.15': - resolution: {integrity: sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A==} + '@biomejs/cli-win32-x64@2.4.0': + resolution: {integrity: sha512-g47s+V+OqsGxbSZN3lpav6WYOk0PIc3aCBAq+p6dwSynL3K5MA6Cg6nkzDOlu28GEHwbakW+BllzHCJCxnfK5Q==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -4854,6 +4854,9 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + algoliasearch@5.48.1: resolution: {integrity: sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg==} engines: {node: '>= 14.0.0'} @@ -5263,8 +5266,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001769: - resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + caniuse-lite@1.0.30001770: + resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -5980,8 +5983,8 @@ packages: '@typescript-eslint/parser': optional: true - eslint-plugin-jsdoc@62.5.4: - resolution: {integrity: sha512-U+Q5ppErmC17VFQl542eBIaXcuq975BzoIHBXyx7UQx/i4gyHXxPiBkonkuxWyFA98hGLALLUuD+NJcXqSGKxg==} + eslint-plugin-jsdoc@62.5.5: + resolution: {integrity: sha512-aRp0KVW26imgPqn17oSDnJdA3tlu+6D/xI/pqWzK5qDPQbldQ1Hsg84dYAsFyvGJhI+u/sGvzgGjMjlKQtUG/Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 @@ -8544,8 +8547,8 @@ packages: react: '*' react-native: '*' - react-native-quick-crypto@1.0.10: - resolution: {integrity: sha512-dTRnCN2GZoZTKDrO/Bh9rV4txR5RtESHzZLjlTsW91ZoAkyLNhcpDT5f/PFQhU+wu5892DScByRqEPXn8AteJA==} + react-native-quick-crypto@1.0.11: + resolution: {integrity: sha512-Zz2RvzFM+B/B1ak84vMUmBempPPSNw7nXSM1c5FEmGE/B0Y17yYedkKEQLoN532PQnZNubxSRwc4vHy+6p5mLA==} peerDependencies: expo: '>=48.0.0' expo-build-properties: '*' @@ -9374,8 +9377,8 @@ packages: svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 typescript: ^4.9.4 || ^5.0.0 - svelte@5.51.0: - resolution: {integrity: sha512-TOtjs5SjPi1iCXJ7aHwQBY+PEyk0koIc1FCKNFyS9kQuN4FBma9nRuxM4f4A0b5SCdej812vf2Rcql2I0s9FZg==} + svelte@5.51.2: + resolution: {integrity: sha512-AqApqNOxVS97V4Ko9UHTHeSuDJrwauJhZpLDs1gYD8Jk48ntCSWD7NxKje+fnGn5Ja1O3u2FzQZHPdifQjXe3w==} engines: {node: '>=18'} tabbable@6.4.0: @@ -9544,38 +9547,38 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - turbo-darwin-64@2.8.8: - resolution: {integrity: sha512-7R/XRAleyNB8nIJuenRpS7EnmCM/kYFgcG1rW3F5DF57Nl4WyBO3DzxqwooRheuoXDD1UsJvDse0yRufgxjClA==} + turbo-darwin-64@2.8.9: + resolution: {integrity: sha512-KnCw1ZI9KTnEAhdI9avZrnZ/z4wsM++flMA1w8s8PKOqi5daGpFV36qoPafg4S8TmYMe52JPWEoFr0L+lQ5JIw==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.8.8: - resolution: {integrity: sha512-whpveO0w/dMcgQ0zFrzqK76mG2W+RBQ/pKIpW+sEvNr+T4UcYd2gZpfV2euehJLvdeFErWgW74dIt/MFfkG1bA==} + turbo-darwin-arm64@2.8.9: + resolution: {integrity: sha512-CbD5Y2NKJKBXTOZ7z7Cc7vGlFPZkYjApA7ri9lH4iFwKV1X7MoZswh9gyRLetXYWImVX1BqIvP8KftulJg/wIA==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.8.8: - resolution: {integrity: sha512-BISkcKk+5/Dh11q+YhdQIGONXmdlQ6LY0iQ+GBIK7QOBAJyJFKmKvUeAeMdWGXOa3sImz2oZ6u2StyR+0Y/VVA==} + turbo-linux-64@2.8.9: + resolution: {integrity: sha512-OXC9HdCtsHvyH+5KUoH8ds+p5WU13vdif0OPbsFzZca4cUXMwKA3HWwUuCgQetk0iAE4cscXpi/t8A263n3VTg==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.8.8: - resolution: {integrity: sha512-B+DQWWtM3O+AexFF4aYucT0MvvnQOW4n5y9LA5h3Zc3Lpj10yl9K4dEBzJvMVQ2jTkCRNAvA8q9G3EjqmGmGGA==} + turbo-linux-arm64@2.8.9: + resolution: {integrity: sha512-yI5n8jNXiFA6+CxnXG0gO7h5ZF1+19K8uO3/kXPQmyl37AdiA7ehKJQOvf9OPAnmkGDHcF2HSCPltabERNRmug==} cpu: [arm64] os: [linux] - turbo-windows-64@2.8.8: - resolution: {integrity: sha512-KP5TRVrmVnrxDyvERvQq7VXu+h5AiEiBwyr90YsRuYy/Z5zLz8hrNZXejTyUR17xmnuI+RbUSxrQlfmPnxiW1A==} + turbo-windows-64@2.8.9: + resolution: {integrity: sha512-/OztzeGftJAg258M/9vK2ZCkUKUzqrWXJIikiD2pm8TlqHcIYUmepDbyZSDfOiUjMy6NzrLFahpNLnY7b5vNgg==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.8.8: - resolution: {integrity: sha512-X45sTOksR8iidk+PZUXpNcfEkOhGCTS2p2J8nSUExr1v7u3T4FixwplzliuWmG7QTPGJapAwItzHbi+d/9ckNQ==} + turbo-windows-arm64@2.8.9: + resolution: {integrity: sha512-xZ2VTwVTjIqpFZKN4UBxDHCPM3oJ2J5cpRzCBSmRpJ/Pn33wpiYjs+9FB2E03svKaD04/lSSLlEUej0UYsugfg==} cpu: [arm64] os: [win32] - turbo@2.8.8: - resolution: {integrity: sha512-wP+axjWAKzYfZ7bghuggVST9bX4j5cn3NPMU9NPQqtwaUKL9n9JOnWWBIc9gdrEma1aViYR73EoPjDEpVq+liQ==} + turbo@2.8.9: + resolution: {integrity: sha512-G+Mq8VVQAlpz/0HTsxiNNk/xywaHGl+dk1oiBREgOEVCCDjXInDlONWUn5srRnC9s5tdHTFD1bx1N19eR4hI+g==} hasBin: true type-check@0.4.0: @@ -10029,8 +10032,8 @@ packages: resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==} engines: {node: '>=8'} - webpack-sources@3.3.3: - resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} + webpack-sources@3.3.4: + resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} engines: {node: '>=10.13.0'} webpack@5.105.2: @@ -10531,9 +10534,9 @@ snapshots: '@angular/core': 21.1.4(@angular/compiler@21.1.4)(rxjs@7.8.2) tslib: 2.8.1 - '@apideck/better-ajv-errors@0.3.6(ajv@8.17.1)': + '@apideck/better-ajv-errors@0.3.6(ajv@8.18.0)': dependencies: - ajv: 8.17.1 + ajv: 8.18.0 json-schema: 0.4.0 jsonpointer: 5.0.1 leven: 3.1.0 @@ -11443,39 +11446,39 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@biomejs/biome@2.3.15': + '@biomejs/biome@2.4.0': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.3.15 - '@biomejs/cli-darwin-x64': 2.3.15 - '@biomejs/cli-linux-arm64': 2.3.15 - '@biomejs/cli-linux-arm64-musl': 2.3.15 - '@biomejs/cli-linux-x64': 2.3.15 - '@biomejs/cli-linux-x64-musl': 2.3.15 - '@biomejs/cli-win32-arm64': 2.3.15 - '@biomejs/cli-win32-x64': 2.3.15 + '@biomejs/cli-darwin-arm64': 2.4.0 + '@biomejs/cli-darwin-x64': 2.4.0 + '@biomejs/cli-linux-arm64': 2.4.0 + '@biomejs/cli-linux-arm64-musl': 2.4.0 + '@biomejs/cli-linux-x64': 2.4.0 + '@biomejs/cli-linux-x64-musl': 2.4.0 + '@biomejs/cli-win32-arm64': 2.4.0 + '@biomejs/cli-win32-x64': 2.4.0 - '@biomejs/cli-darwin-arm64@2.3.15': + '@biomejs/cli-darwin-arm64@2.4.0': optional: true - '@biomejs/cli-darwin-x64@2.3.15': + '@biomejs/cli-darwin-x64@2.4.0': optional: true - '@biomejs/cli-linux-arm64-musl@2.3.15': + '@biomejs/cli-linux-arm64-musl@2.4.0': optional: true - '@biomejs/cli-linux-arm64@2.3.15': + '@biomejs/cli-linux-arm64@2.4.0': optional: true - '@biomejs/cli-linux-x64-musl@2.3.15': + '@biomejs/cli-linux-x64-musl@2.4.0': optional: true - '@biomejs/cli-linux-x64@2.3.15': + '@biomejs/cli-linux-x64@2.4.0': optional: true - '@biomejs/cli-win32-arm64@2.3.15': + '@biomejs/cli-win32-arm64@2.4.0': optional: true - '@biomejs/cli-win32-x64@2.3.15': + '@biomejs/cli-win32-x64@2.4.0': optional: true '@blazejkustra/react-native-alert@1.0.0(react-native@0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4))(react@19.2.4)': @@ -14087,31 +14090,31 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/package@2.5.7(svelte@5.51.0)(typescript@5.9.3)': + '@sveltejs/package@2.5.7(svelte@5.51.2)(typescript@5.9.3)': dependencies: chokidar: 5.0.0 kleur: 4.1.5 sade: 1.8.1 semver: 7.7.4 - svelte: 5.51.0 - svelte2tsx: 0.7.48(svelte@5.51.0)(typescript@5.9.3) + svelte: 5.51.2 + svelte2tsx: 0.7.48(svelte@5.51.2)(typescript@5.9.3) transitivePeerDependencies: - typescript - '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)))(svelte@5.51.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)))(svelte@5.51.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.51.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.51.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)) obug: 2.1.1 - svelte: 5.51.0 + svelte: 5.51.2 vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2) - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)))(svelte@5.51.0)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)))(svelte@5.51.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 - svelte: 5.51.0 + svelte: 5.51.2 vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2) vitefu: 1.1.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.46.0)(yaml@2.8.2)) @@ -14901,9 +14904,9 @@ snapshots: agent-base@7.1.4: {} - ajv-formats@2.1.1(ajv@8.17.1): + ajv-formats@2.1.1(ajv@8.18.0): optionalDependencies: - ajv: 8.17.1 + ajv: 8.18.0 ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: @@ -14913,9 +14916,9 @@ snapshots: dependencies: ajv: 6.12.6 - ajv-keywords@5.1.0(ajv@8.17.1): + ajv-keywords@5.1.0(ajv@8.18.0): dependencies: - ajv: 8.17.1 + ajv: 8.18.0 fast-deep-equal: 3.1.3 ajv@6.12.6: @@ -14932,6 +14935,13 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + algoliasearch@5.48.1: dependencies: '@algolia/abtesting': 1.14.1 @@ -15392,7 +15402,7 @@ snapshots: browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 + caniuse-lite: 1.0.30001770 electron-to-chromium: 1.5.286 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -15497,7 +15507,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001769: {} + caniuse-lite@1.0.30001770: {} ccount@2.0.1: {} @@ -15941,7 +15951,7 @@ snapshots: dotenv-expand@11.0.7: dependencies: - dotenv: 16.4.7 + dotenv: 16.6.1 dotenv@16.4.7: {} @@ -16410,7 +16420,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsdoc@62.5.4(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-jsdoc@62.5.5(eslint@9.39.2(jiti@2.6.1)): dependencies: '@es-joy/jsdoccomment': 0.84.0 '@es-joy/resolve.exports': 1.2.0 @@ -18992,7 +19002,7 @@ snapshots: '@next/env': 16.1.6 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 + caniuse-lite: 1.0.30001770 postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -19605,7 +19615,7 @@ snapshots: react: 19.2.4 react-native: 0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4) - react-native-quick-crypto@1.0.10(expo@54.0.33)(react-native-nitro-modules@0.31.10(react-native@0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4))(react@19.2.4))(react-native-quick-base64@2.2.2(react-native@0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4))(react@19.2.4))(react-native@0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4))(react@19.2.4): + react-native-quick-crypto@1.0.11(expo@54.0.33)(react-native-nitro-modules@0.31.10(react-native@0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4))(react@19.2.4))(react-native-quick-base64@2.2.2(react-native@0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4))(react@19.2.4))(react-native@0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4))(react@19.2.4): dependencies: '@craftzdog/react-native-buffer': 6.1.0(react-native@0.81.6(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.2.4))(react@19.2.4) events: 3.3.0 @@ -20210,9 +20220,9 @@ snapshots: schema-utils@4.3.3: dependencies: '@types/json-schema': 7.0.15 - ajv: 8.17.1 - ajv-formats: 2.1.1(ajv@8.17.1) - ajv-keywords: 5.1.0(ajv@8.17.1) + ajv: 8.18.0 + ajv-formats: 2.1.1(ajv@8.18.0) + ajv-keywords: 5.1.0(ajv@8.18.0) scule@1.3.0: {} @@ -20803,26 +20813,26 @@ snapshots: has-property-descriptors: 1.0.2 set-function-name: 2.0.2 - svelte-check@4.4.0(picomatch@4.0.3)(svelte@5.51.0)(typescript@5.9.3): + svelte-check@4.4.0(picomatch@4.0.3)(svelte@5.51.2)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.3) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.51.0 + svelte: 5.51.2 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte2tsx@0.7.48(svelte@5.51.0)(typescript@5.9.3): + svelte2tsx@0.7.48(svelte@5.51.2)(typescript@5.9.3): dependencies: dedent-js: 1.0.1 scule: 1.3.0 - svelte: 5.51.0 + svelte: 5.51.2 typescript: 5.9.3 - svelte@5.51.0: + svelte@5.51.2: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -21006,32 +21016,32 @@ snapshots: dependencies: safe-buffer: 5.2.1 - turbo-darwin-64@2.8.8: + turbo-darwin-64@2.8.9: optional: true - turbo-darwin-arm64@2.8.8: + turbo-darwin-arm64@2.8.9: optional: true - turbo-linux-64@2.8.8: + turbo-linux-64@2.8.9: optional: true - turbo-linux-arm64@2.8.8: + turbo-linux-arm64@2.8.9: optional: true - turbo-windows-64@2.8.8: + turbo-windows-64@2.8.9: optional: true - turbo-windows-arm64@2.8.8: + turbo-windows-arm64@2.8.9: optional: true - turbo@2.8.8: + turbo@2.8.9: optionalDependencies: - turbo-darwin-64: 2.8.8 - turbo-darwin-arm64: 2.8.8 - turbo-linux-64: 2.8.8 - turbo-linux-arm64: 2.8.8 - turbo-windows-64: 2.8.8 - turbo-windows-arm64: 2.8.8 + turbo-darwin-64: 2.8.9 + turbo-darwin-arm64: 2.8.9 + turbo-linux-64: 2.8.9 + turbo-linux-arm64: 2.8.9 + turbo-windows-64: 2.8.9 + turbo-windows-arm64: 2.8.9 type-check@0.4.0: dependencies: @@ -21504,7 +21514,7 @@ snapshots: webidl-conversions@5.0.0: {} - webpack-sources@3.3.3: {} + webpack-sources@3.3.4: {} webpack@5.105.2: dependencies: @@ -21532,7 +21542,7 @@ snapshots: tapable: 2.3.0 terser-webpack-plugin: 5.3.16(webpack@5.105.2) watchpack: 2.5.1 - webpack-sources: 3.3.3 + webpack-sources: 3.3.4 transitivePeerDependencies: - '@swc/core' - esbuild @@ -21621,7 +21631,7 @@ snapshots: workbox-build@7.4.0(@types/babel__core@7.20.5): dependencies: - '@apideck/better-ajv-errors': 0.3.6(ajv@8.17.1) + '@apideck/better-ajv-errors': 0.3.6(ajv@8.18.0) '@babel/core': 7.29.0 '@babel/preset-env': 7.29.0(@babel/core@7.29.0) '@babel/runtime': 7.28.6 @@ -21630,7 +21640,7 @@ snapshots: '@rollup/plugin-replace': 2.4.2(rollup@2.79.2) '@rollup/plugin-terser': 0.4.4(rollup@2.79.2) '@surma/rollup-plugin-off-main-thread': 2.2.3 - ajv: 8.17.1 + ajv: 8.18.0 common-tags: 1.8.2 fast-json-stable-stringify: 2.1.0 fs-extra: 9.1.0 From b9fcb47d7de5dbd99732e85346979813f1f14226 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 23:12:44 +0100 Subject: [PATCH 35/60] Add getOk for never-error Results Add getOk to Result utilities to safely extract values from Result with an assertion guard. Add tests covering getOk success path, type-level constraints, and runtime invariant violation. Include getOrNull tests to keep Result extraction helpers consistently covered. --- packages/common/src/Result.ts | 17 ++++++++++++++- packages/common/test/Result.test.ts | 32 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/common/src/Result.ts b/packages/common/src/Result.ts index e04a55ba8..7bd2636c2 100644 --- a/packages/common/src/Result.ts +++ b/packages/common/src/Result.ts @@ -10,6 +10,7 @@ import { isNonEmptyArray, type NonEmptyReadonlyArray, } from "./Array.js"; +import { assert } from "./Assert.js"; import type { UnknownError } from "./Error.js"; import type { Lazy } from "./Function.js"; import { exhaustiveCheck } from "./Function.js"; @@ -199,7 +200,9 @@ import type { Typed } from "./Type.js"; * switch (error.type) { * case "TimestampError": * console.error(error.error.stack); // Log preserved stack trace - * showToast("Timestamp error. Your computer clock appears to be incorrect."); + * showToast( + * "Timestamp error. Your computer clock appears to be incorrect.", + * ); * break; * case "SyncError": * showToast("Sync failed. Retrying..."); @@ -379,6 +382,18 @@ export const getOrThrow = (result: Result): T => { export const getOrNull = (result: Result): T | null => result.ok ? result.value : null; +/** + * Extracts the value from a {@link Result} whose error type is `never`. + * + * This is useful when the type system guarantees the result cannot fail (for + * example `Result`), avoiding impossible `if (!result.ok)` branches + * at call sites. + */ +export const getOk = (result: Result): T => { + assert(result.ok, "Expected Ok result."); + return result.value; +}; + /** * Wraps a synchronous function that may throw, returning a {@link Result}. * diff --git a/packages/common/test/Result.test.ts b/packages/common/test/Result.test.ts index a9624afaf..55f90f868 100644 --- a/packages/common/test/Result.test.ts +++ b/packages/common/test/Result.test.ts @@ -5,6 +5,8 @@ import { anyResult, done, err, + getOk, + getOrNull, getOrThrow, isErr, isOk, @@ -131,6 +133,36 @@ describe("getOrThrow", () => { }); }); +describe("getOrNull", () => { + it("returns value for Ok", () => { + expect(getOrNull(ok(42))).toBe(42); + }); + + it("returns null for Err", () => { + expect(getOrNull(err("error"))).toBeNull(); + }); +}); + +describe("getOk", () => { + it("extracts value from Result with never error", () => { + const result = ok(42); + expect(getOk(result)).toBe(42); + }); + + it("rejects Result with possible error type", () => { + type IsAssignable = + Result extends Result ? true : false; + expectTypeOf().toEqualTypeOf(); + }); + + it("throws when invariant is violated at runtime", () => { + const invalid = err("fail") as unknown as Result; + expect(() => getOk(invalid)).toThrowErrorMatchingInlineSnapshot( + `[Error: Expected Ok result.]`, + ); + }); +}); + describe("trySync", () => { interface ParseError { readonly type: "ParseError"; From af2e3f6252fe09da16786f9bb2d4033b48389112 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 23:13:27 +0100 Subject: [PATCH 36/60] Use getOk in SQLite setup paths Replace manual Result unwrapping with getOk in DB and relay SQLite initialization paths. Simplify dependent code by working with unwrapped sqlite values directly. Update shared test deps helper to align with the same unwrapping pattern. --- packages/common/src/local-first/Db.ts | 14 +++++--------- packages/common/test/_deps.ts | 9 +++++---- packages/nodejs/src/local-first/Relay.ts | 10 +++++----- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index d890799e4..7188171e6 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -9,7 +9,7 @@ import { firstInArray } from "../Array.js"; import { assertNonEmptyReadonlyArray } from "../Assert.js"; import { EncryptionKey, type RandomBytesDep } from "../Crypto.js"; import type { LeaderLockDep } from "../Platform.js"; -import { ok } from "../Result.js"; +import { getOk, ok } from "../Result.js"; import type { CreateSqliteDriverDep, SqliteDep } from "../Sqlite.js"; import { createSqlite, sql } from "../Sqlite.js"; import type { AsyncDisposableStack, Task } from "../Task.js"; @@ -123,17 +123,13 @@ const startDbWorker = const { port } = run.deps; const _console = run.deps.console.child(name).child("DbWorker"); - const sqlite = await stack.use( - createSqlite(name, { mode: "encrypted", encryptionKey }), + const sqlite = getOk( + await stack.use(createSqlite(name, { mode: "encrypted", encryptionKey })), ); - if (!sqlite.ok) { - port.postMessage({ type: "EvoluError", error: sqlite.error }); - return ok(stack.move()); - } - const deps = { ...run.deps, sqlite: sqlite.value }; + const deps = { ...run.deps, sqlite }; - const result = sqlite.value.transaction(() => { + const result = sqlite.transaction(() => { const dbSchema = getDbSchema(deps)(); const dbIsInitialized = "evolu_version" in dbSchema.tables; diff --git a/packages/common/test/_deps.ts b/packages/common/test/_deps.ts index 6835d5dfc..483912b13 100644 --- a/packages/common/test/_deps.ts +++ b/packages/common/test/_deps.ts @@ -34,15 +34,16 @@ export const testCreateRunWithSqlite = async (): Promise< createSqliteDriver: testCreateSqliteDriver, }); - const sqlite = await run(createSqlite(testName)); - assert(sqlite.ok, "bug"); + const sqliteResult = await run(createSqlite(testName)); + assert(sqliteResult.ok, "bug"); + const sqlite = sqliteResult.value; run.defer(() => { - sqlite.value[Symbol.dispose](); + sqlite[Symbol.dispose](); return ok(); }); - return run.addDeps({ sqlite: sqlite.value }); + return run.addDeps({ sqlite }); }; /** Creates a test Run with relay storage and SQLite deps. */ diff --git a/packages/nodejs/src/local-first/Relay.ts b/packages/nodejs/src/local-first/Relay.ts index ba6a9c370..c57a08e79 100644 --- a/packages/nodejs/src/local-first/Relay.ts +++ b/packages/nodejs/src/local-first/Relay.ts @@ -4,11 +4,12 @@ import { createRelation, createSqlite, type CreateSqliteDriverDep, + getOk, isPromiseLike, + Name, ok, OwnerId, type RandomDep, - Name, type Task, type TimingSafeEqualDep, Uint8Array, @@ -27,8 +28,8 @@ import { import { existsSync } from "fs"; import { createServer } from "http"; import { WebSocket, WebSocketServer } from "ws"; -import { createBetterSqliteDriver } from "../Sqlite.js"; import { createTimingSafeEqual } from "../Crypto.js"; +import { createBetterSqliteDriver } from "../Sqlite.js"; export interface NodeJsRelayConfig extends RelayConfig { /** The port number for the HTTP server. */ @@ -76,9 +77,8 @@ export const startRelay = const dbFileExists = existsSync(`${name}.db`); - const sqliteResult = await stack.use(createSqlite(name)); - if (!sqliteResult.ok) return sqliteResult; - const deps = { ..._run.deps, sqlite: sqliteResult.value }; + const sqlite = getOk(await stack.use(createSqlite(name))); + const deps = { ..._run.deps, sqlite }; if (!dbFileExists) { createBaseSqliteStorageTables(deps); From 717abb42e29304af2be015548795564c163f8cf7 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Mon, 16 Feb 2026 00:05:22 +0100 Subject: [PATCH 37/60] Upgrade Biome to v2.4.0 and update config --- biome.json | 13 +++---------- package.json | 2 +- pnpm-lock.yaml | 2 +- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/biome.json b/biome.json index 09fac64da..6151de275 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.0/schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -21,7 +21,8 @@ "noShadowRestrictedNames": "off", "noExplicitAny": "off", "noImplicitAnyLet": "off", - "useIterableCallbackReturn": "off" + "useIterableCallbackReturn": "off", + "noImportCycles": "error" }, "correctness": { "noConstantCondition": "off", @@ -29,14 +30,6 @@ }, "style": { "useImportType": "off" - }, - "nursery": { - "noImportCycles": { - "level": "error", - "options": { - "ignoreTypes": true - } - } } } }, diff --git a/package.json b/package.json index 10e7ade3d..0d3cec568 100755 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "examples:vue-vite-pwa:dev": "turbo --filter @example/vue-vite-pwa dev" }, "devDependencies": { - "@biomejs/biome": "^2.3.14", + "@biomejs/biome": "^2.4.0", "@changesets/cli": "^2.29.8", "@eslint/js": "^9.39.2", "@types/inquirer": "^9.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46200ad45..b0e0dbf74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ importers: .: devDependencies: '@biomejs/biome': - specifier: ^2.3.14 + specifier: ^2.4.0 version: 2.4.0 '@changesets/cli': specifier: ^2.29.8 From c089ae9486a1c7345eee29f32d6cd39eb33c1803 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Mon, 16 Feb 2026 01:06:40 +0100 Subject: [PATCH 38/60] Queue mutations, add quarantine, rename messages Rename inter-worker message types (e.g. "init" -> "Init", "mutate" -> "Mutate") and update message port generics accordingly. Add DbWorkerLeaderInput/Output changes and an OnMutate ack path so SharedWorker can wait for leader acknowledgement. Move and implement quarantined message handling into Db: create evolu_message_quarantine table, add validateColumnValue/applyColumnChange utilities, and tryApplyQuarantinedMessages to reapply quarantined entries after schema updates. Refactor DB initialization/clock handling (createClock, ensureDbSchema, apply quarantined messages after schema ensure). Implement mutation queuing and processing in SharedWorker with per-Evolu queues, retry schedule (exponential + jitter), ack callbacks, and queue processors. Update tests to reflect renamed message types and channel generics. Minor changes: insert example todo in minimal playground, adjust Relay run/deps wiring, and related import/type updates. --- .../minimal/EvoluMinimalExample.tsx | 9 + packages/common/src/local-first/Db.ts | 281 +++++++++++++----- packages/common/src/local-first/Evolu.ts | 14 +- packages/common/src/local-first/Shared.ts | 151 ++++++++-- packages/common/src/local-first/Sync.ts | 48 --- .../common/test/local-first/Evolu.test.ts | 8 +- .../common/test/local-first/Shared.test.ts | 17 +- packages/nodejs/src/local-first/Relay.ts | 8 +- 8 files changed, 370 insertions(+), 166 deletions(-) diff --git a/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx b/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx index 5a6725d68..60d5f0c92 100644 --- a/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx +++ b/apps/web/src/app/(playgrounds)/playgrounds/minimal/EvoluMinimalExample.tsx @@ -72,6 +72,15 @@ const app = run( }), ); +app.then((appResult) => { + if (!appResult.ok) return; + const app = appResult.value; + + app.insert("todo", { + title: Evolu.NonEmptyString100.orThrow("Foo"), + }); +}); + const [App, AppProvider] = createEvoluContext(app); export const EvoluMinimalExample: FC = () => ( diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index 7188171e6..6f5798bff 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -4,16 +4,19 @@ * @module */ -import type { ConsoleLevel } from "../Console.js"; import { firstInArray } from "../Array.js"; import { assertNonEmptyReadonlyArray } from "../Assert.js"; +import type { CallbackId } from "../Callbacks.js"; +import type { ConsoleLevel } from "../Console.js"; import { EncryptionKey, type RandomBytesDep } from "../Crypto.js"; +import { getProperty } from "../Object.js"; import type { LeaderLockDep } from "../Platform.js"; import { getOk, ok } from "../Result.js"; import type { CreateSqliteDriverDep, SqliteDep } from "../Sqlite.js"; -import { createSqlite, sql } from "../Sqlite.js"; +import { createSqlite, sql, SqliteValue } from "../Sqlite.js"; import type { AsyncDisposableStack, Task } from "../Task.js"; import type { Name } from "../Type.js"; +import { Id, IdBytes, idBytesToId } from "../Type.js"; import type { MessagePort, NativeMessagePort, @@ -21,9 +24,12 @@ import type { WorkerDeps, WorkerSelf, } from "../Worker.js"; +import type { OwnerId, OwnerIdBytes } from "./Owner.js"; +import { ownerIdBytesToOwnerId } from "./Owner.js"; import { protocolVersion } from "./Protocol.js"; -import type { DbSchema } from "./Schema.js"; -import { getDbSchema } from "./Schema.js"; +import type { Query } from "./Query.js"; +import type { DbSchema, MutationChange } from "./Schema.js"; +import { ensureDbSchema, getDbSchema, systemColumns } from "./Schema.js"; import type { EvoluTabOutput } from "./Shared.js"; import { createBaseSqliteStorageTables } from "./Storage.js"; import type { Timestamp } from "./Timestamp.js"; @@ -35,12 +41,12 @@ import { } from "./Timestamp.js"; export interface DbWorkerInput { - readonly type: "init"; + readonly type: "Init"; readonly name: Name; readonly consoleLevel: ConsoleLevel; readonly dbSchema: DbSchema; readonly encryptionKey: EncryptionKey; - readonly port: NativeMessagePort; + readonly port: NativeMessagePort; } export type DbWorker = Worker; @@ -53,15 +59,27 @@ export interface CreateDbWorkerDep { export type DbWorkerDeps = WorkerDeps & LeaderLockDep & CreateSqliteDriverDep; +export interface DbWorkerLeaderInput { + readonly type: "Mutate"; + readonly requestId: CallbackId; + readonly changes: ReadonlyArray; + readonly onCompleteIds: ReadonlyArray; + readonly subscribedQueries: ReadonlyArray; +} + export type DbWorkerLeaderOutput = | { readonly type: "LeaderAcquired"; readonly name: Name; } + | { + readonly type: "OnMutate"; + readonly requestId: CallbackId; + } | EvoluTabOutput; export interface PortDep { - readonly port: MessagePort; + readonly port: MessagePort; } export const initDbWorker = @@ -88,7 +106,9 @@ export const initDbWorker = console.info("initDbWorker"); const port = stack.use( - createMessagePort(nativeLeaderPort), + createMessagePort( + nativeLeaderPort, + ), ); stack.defer( @@ -115,12 +135,12 @@ export const initDbWorker = const startDbWorker = ( name: Name, - _dbSchema: DbSchema, + dbSchema: DbSchema, encryptionKey: EncryptionKey, ): Task => async (run) => { await using stack = run.stack(); - const { port } = run.deps; + const { port: _port } = run.deps; const _console = run.deps.console.child(name).child("DbWorker"); const sqlite = getOk( @@ -129,24 +149,22 @@ const startDbWorker = const deps = { ...run.deps, sqlite }; - const result = sqlite.transaction(() => { - const dbSchema = getDbSchema(deps)(); - - const dbIsInitialized = "evolu_version" in dbSchema.tables; + sqlite.transaction(() => { + const currentSchema = getDbSchema(deps)(); + const dbIsInitialized = "evolu_version" in currentSchema.tables; const clock = createClock(deps)(dbIsInitialized); - if (!dbIsInitialized) { - initializeDb(deps)(clock.get()); - } + if (!dbIsInitialized) initializeDb(deps)(clock.get()); + + ensureDbSchema(deps)(dbSchema, currentSchema); - return ok(); + tryApplyQuarantinedMessages(deps)(dbSchema); }); - if (!result.ok) { - port.postMessage({ type: "EvoluError", error: result.error }); - return ok(stack.move()); - } + _port.onMessage = (message) => { + _port.postMessage({ type: "OnMutate", requestId: message.requestId }); + }; // run.deps.leaderPort.onMessage({ // // @@ -161,6 +179,46 @@ const startDbWorker = // to announce itself alive and then routes requests to that worker. }; +interface Clock { + readonly get: () => Timestamp; + readonly save: (timestamp: Timestamp) => void; +} + +// interface ClockDep { +// readonly clock: Clock; +// } + +const createClock = + (deps: RandomBytesDep & SqliteDep) => + (dbIsInitialized: boolean): Clock => { + let currentTimestamp: Timestamp; + + if (dbIsInitialized) { + const { rows } = deps.sqlite.exec<{ clock: TimestampBytes }>(sql` + select clock + from evolu_config + limit 1; + `); + assertNonEmptyReadonlyArray(rows); + currentTimestamp = timestampBytesToTimestamp(firstInArray(rows).clock); + } else { + currentTimestamp = createInitialTimestamp(deps); + } + + return { + get: () => currentTimestamp, + + save: (timestamp) => { + currentTimestamp = timestamp; + + deps.sqlite.exec(sql.prepared` + update evolu_config + set "clock" = ${timestampToTimestampBytes(timestamp)}; + `); + }, + }; + }; + const initializeDb = ({ sqlite }: SqliteDep) => (initialClock: Timestamp): void => { @@ -225,6 +283,36 @@ const initializeDb = "timestamp" desc ); `, + + /** + * Stores messages with unknown schema in a quarantine table. + * + * When a device receives sync messages containing tables or columns that + * don't exist in its current schema (e.g., from a newer app version), + * those messages are stored here instead of being discarded. This enables + * forward compatibility: + * + * 1. Unknown data is preserved and can be applied when the app is updated + * 2. Messages are still propagated to other devices that may understand them + * 3. Partial messages work - known columns go to app tables, unknown to + * quarantine + * + * The `union all` query in `readDbChange` combines `evolu_history` and + * this table, ensuring all data (known and unknown) is included when + * syncing to other devices. + */ + sql` + create table evolu_message_quarantine ( + "ownerId" blob not null, + "timestamp" blob not null, + "table" text not null, + "id" blob not null, + "column" text not null, + "value" any, + primary key ("ownerId", "timestamp", "table", "id", "column") + ) + strict; + `, ]) { sqlite.exec(query); } @@ -232,45 +320,113 @@ const initializeDb = createBaseSqliteStorageTables({ sqlite }); }; -interface Clock { - readonly get: () => Timestamp; - readonly save: (timestamp: Timestamp) => void; -} - -// interface ClockDep { -// readonly clock: Clock; -// } - -const createClock = - (deps: RandomBytesDep & SqliteDep) => - (dbIsInitialized: boolean): Clock => { - let currentTimestamp: Timestamp; - - if (dbIsInitialized) { - const configResult = deps.sqlite.exec<{ clock: TimestampBytes }>(sql` - select clock - from evolu_config - limit 1; +const tryApplyQuarantinedMessages = + (deps: SqliteDep) => + (dbSchema: DbSchema): void => { + const rows = deps.sqlite.exec<{ + readonly ownerId: OwnerIdBytes; + readonly timestamp: TimestampBytes; + readonly table: string; + readonly id: IdBytes; + readonly column: string; + readonly value: SqliteValue; + }>(sql` + select "ownerId", "timestamp", "table", "id", "column", "value" + from evolu_message_quarantine; + `); + + for (const row of rows.rows) { + if (!validateColumnValue(dbSchema, row.table, row.column, row.value)) + continue; + + applyColumnChange(deps)( + row.ownerId, + ownerIdBytesToOwnerId(row.ownerId), + row.table, + row.id, + idBytesToId(row.id), + row.column, + row.value, + row.timestamp, + ); + + deps.sqlite.exec(sql` + delete from evolu_message_quarantine + where + "ownerId" = ${row.ownerId} + and "timestamp" = ${row.timestamp} + and "table" = ${row.table} + and "id" = ${row.id} + and "column" = ${row.column}; `); - assertNonEmptyReadonlyArray(configResult.rows); - const config = firstInArray(configResult.rows); - currentTimestamp = timestampBytesToTimestamp(config.clock); - } else { - currentTimestamp = createInitialTimestamp(deps); } + }; - return { - get: () => currentTimestamp, - - save: (timestamp) => { - currentTimestamp = timestamp; - - deps.sqlite.exec(sql.prepared` - update evolu_config - set "clock" = ${timestampToTimestampBytes(timestamp)}; - `); - }, - }; +const validateColumnValue = ( + dbSchema: DbSchema, + table: string, + column: string, + _value: SqliteValue, +): boolean => { + const schemaColumns = getProperty(dbSchema.tables, table); + return ( + schemaColumns != null && + (systemColumnsWithoutOwnerId.has(column) || schemaColumns.has(column)) + ); +}; + +const systemColumnsWithoutOwnerId = systemColumns.difference( + new Set(["ownerId"]), +); + +const applyColumnChange = + (deps: SqliteDep) => + ( + ownerIdBytes: OwnerIdBytes, + ownerId: OwnerId, + table: string, + idBytes: IdBytes, + id: Id, + column: string, + value: SqliteValue, + timestampBytes: TimestampBytes, + ): void => { + deps.sqlite.exec(sql.prepared` + with + existingTimestamp as ( + select 1 + from evolu_history + where + "ownerId" = ${ownerIdBytes} + and "table" = ${table} + and "id" = ${idBytes} + and "column" = ${column} + and "timestamp" >= ${timestampBytes} + limit 1 + ) + insert into ${sql.identifier(table)} + ("ownerId", "id", ${sql.identifier(column)}) + select ${ownerId}, ${id}, ${value} + where not exists (select 1 from existingTimestamp) + on conflict ("ownerId", "id") do update + set ${sql.identifier(column)} = ${value} + where not exists (select 1 from existingTimestamp); + `); + + deps.sqlite.exec(sql.prepared` + insert into evolu_history + ("ownerId", "table", "id", "column", "value", "timestamp") + values + ( + ${ownerIdBytes}, + ${table}, + ${idBytes}, + ${column}, + ${value}, + ${timestampBytes} + ) + on conflict do nothing; + `); }; // import { @@ -338,7 +494,6 @@ const createClock = // createSync, // SyncDep, // SyncOwner, -// tryApplyQuarantinedMessages, // } from "./Sync.js"; // import { // Timestamp, @@ -707,14 +862,6 @@ const createClock = // }); // if (!sync.ok) return sync; -// { -// const result = tryApplyQuarantinedMessages({ -// ...deps, -// dbSchema: initMessage.dbSchema, -// })(); -// if (!result.ok) return result; -// } - // sync.value.useOwner(true, appOwner); // return ok({ diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index c466660c8..5673aa8aa 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -29,7 +29,11 @@ import { UrlSafeString, } from "../Type.js"; import type { CreateMessageChannelDep } from "../Worker.js"; -import type { CreateDbWorkerDep, DbWorkerLeaderOutput } from "./Db.js"; +import type { + CreateDbWorkerDep, + DbWorkerLeaderInput, + DbWorkerLeaderOutput, +} from "./Db.js"; import type { EvoluError } from "./Error.js"; import type { AppOwner, OwnerTransport } from "./Owner.js"; import { @@ -487,7 +491,9 @@ export const createEvolu = async (run) => { const { appName, appOwner = createAppOwner(createOwnerSecret(run.deps)) } = config; + const name = Name.orThrow(`${appName}-${createIdFromString(appOwner.id)}`); + const console = run.deps.console.child(name).child("Evolu"); console.info("createEvolu", { config }); @@ -506,7 +512,7 @@ export const createEvolu = const { createDbWorker, createMessageChannel, sharedWorker } = run.deps; // For DbWorker to announce to SharedWorker that it is the leader. const leaderChannel = stack.use( - createMessageChannel(), + createMessageChannel(), ); // For Evolu to send and receive messages with SharedWorker. const evoluChannel = stack.use(createMessageChannel()); @@ -523,7 +529,7 @@ export const createEvolu = stack.use(createDbWorker()).postMessage( { - type: "init", + type: "Init", name, consoleLevel: console.getLevel(), dbSchema: evoluSchemaToDbSchema(schema, config.indexes), @@ -541,7 +547,7 @@ export const createEvolu = readonly onComplete: (() => void) | undefined; }>((items) => { postMessage({ - type: "mutate", + type: "Mutate", changes: mapArray(items, (item) => item.change), onCompleteIds: items.flatMap((item) => item.onComplete diff --git a/packages/common/src/local-first/Shared.ts b/packages/common/src/local-first/Shared.ts index 4f4f4dd5d..20dbc1c2c 100644 --- a/packages/common/src/local-first/Shared.ts +++ b/packages/common/src/local-first/Shared.ts @@ -6,10 +6,12 @@ import type { NonEmptyReadonlyArray } from "../Array.js"; import type { CallbackId } from "../Callbacks.js"; +import { createCallbacks } from "../Callbacks.js"; import type { ConsoleEntry, ConsoleLevel } from "../Console.js"; import { exhaustiveCheck } from "../Function.js"; import { ok } from "../Result.js"; -import type { Task } from "../Task.js"; +import { exponential, jitter, maxDelay } from "../Schedule.js"; +import { sleep, timeout, type Task } from "../Task.js"; import type { Name } from "../Type.js"; import type { SharedWorker as CommonSharedWorker, @@ -18,7 +20,7 @@ import type { SharedWorkerSelf, WorkerDeps, } from "../Worker.js"; -import type { DbWorkerLeaderOutput } from "./Db.js"; +import type { DbWorkerLeaderInput, DbWorkerLeaderOutput } from "./Db.js"; import type { EvoluError } from "./Error.js"; import type { Query } from "./Query.js"; import type { MutationChange } from "./Schema.js"; @@ -36,7 +38,7 @@ export interface SharedWorkerDep { * request variants will be added as query and owner flows are implemented. */ export interface EvoluInput { - readonly type: "mutate"; + readonly type: "Mutate"; readonly changes: NonEmptyReadonlyArray; readonly onCompleteIds: ReadonlyArray; readonly subscribedQueries: ReadonlyArray; @@ -54,7 +56,10 @@ export type SharedWorkerInput = readonly type: "InitEvolu"; readonly name: Name; readonly port1: NativeMessagePort; - readonly port2: NativeMessagePort; + readonly port2: NativeMessagePort< + DbWorkerLeaderInput, + DbWorkerLeaderOutput + >; }; export type EvoluTabOutput = @@ -80,16 +85,89 @@ export const initSharedWorker = const queuedTabOutputs: Array = []; const leaderPorts = new Map< Name, - MessagePort + MessagePort >(); + const mutationQueues = new Map>(); + const queueProcessors = new Set(); + const onMutateCallbacks = createCallbacks(run.deps); + + const retrySchedule = jitter(0.5)(maxDelay("5s")(exponential("250ms"))); const postTabOutput = (output: EvoluTabOutput): void => { - for (const port of tabPorts) port.postMessage(output); + if (tabPorts.size === 0) queuedTabOutputs.push(output); + else for (const port of tabPorts) port.postMessage(output); }; - const postOrQueueTabOutput = (output: EvoluTabOutput): void => { - if (tabPorts.size === 0) queuedTabOutputs.push(output); - else postTabOutput(output); + const waitForAck = + (ackPromise: Promise): Task => + async () => { + await ackPromise; + return ok(); + }; + + const ensureMutationQueue = (name: Name): Array => { + let queue = mutationQueues.get(name); + if (!queue) { + queue = []; + mutationQueues.set(name, queue); + } + return queue; + }; + + const processMutationQueue = (name: Name): void => { + if ((mutationQueues.get(name)?.length ?? 0) === 0) return; + if (queueProcessors.has(name)) return; + queueProcessors.add(name); + + void run.daemon(async (run) => { + const retryStep = retrySchedule(run.deps); + + while ((mutationQueues.get(name)?.length ?? 0) > 0) { + const queue = mutationQueues.get(name); + if (!queue) break; + + const message = queue[0]; + const leaderPort = leaderPorts.get(name); + + if (!leaderPort) { + const retry = retryStep(undefined); + if (!retry.ok) break; + const r = await run(sleep(retry.value[1])); + if (!r.ok) break; + continue; + } + + if (!message.requestId) { + const { promise, resolve } = Promise.withResolvers(); + message.requestId = onMutateCallbacks.register(resolve); + message.ackPromise = promise; + } + + leaderPort.postMessage({ + type: "Mutate", + requestId: message.requestId, + changes: message.changes, + onCompleteIds: message.onCompleteIds, + subscribedQueries: message.subscribedQueries, + }); + + const ack = await run(timeout(waitForAck(message.ackPromise!), "3s")); + + if (ack.ok) { + queue.shift(); + continue; + } + + const retry = retryStep(undefined); + if (!retry.ok) break; + const r = await run(sleep(retry.value[1])); + if (!r.ok) break; + } + + queueProcessors.delete(name); + processMutationQueue(name); + return ok(); + }); }; await using stack = run.stack(); @@ -97,7 +175,7 @@ export const initSharedWorker = stack.defer( consoleStoreOutputEntry.subscribe(() => { const entry = consoleStoreOutputEntry.get(); - if (entry) postOrQueueTabOutput({ type: "ConsoleEntry", entry }); + if (entry) postTabOutput({ type: "ConsoleEntry", entry }); }), ); @@ -121,46 +199,47 @@ export const initSharedWorker = break; } case "InitEvolu": { - // TODO: Wrap port, do async init (open DB, load owner), - // then set onMessage to start processing requests. - // Messages are queued until onMessage is assigned. + const evoluName = message.name; const evoluPort = createMessagePort( message.port1, ); - const leaderPort = createMessagePort( - message.port2, - ); + const leaderPort = createMessagePort< + DbWorkerLeaderInput, + DbWorkerLeaderOutput + >(message.port2); - leaderPorts.set(message.name, leaderPort); + leaderPorts.set(evoluName, leaderPort); - leaderPort.onMessage = (leaderEvent) => { - switch (leaderEvent.type) { + leaderPort.onMessage = (message) => { + switch (message.type) { case "LeaderAcquired": { - leaderPorts.set(leaderEvent.name, leaderPort); - console.info("leaderAcquired", { name: leaderEvent.name }); + leaderPorts.set(message.name, leaderPort); + console.info("leaderAcquired", { name: message.name }); + processMutationQueue(message.name); break; } - case "ConsoleEntry": { - postOrQueueTabOutput({ - type: "ConsoleEntry", - entry: leaderEvent.entry, - }); + case "OnMutate": { + onMutateCallbacks.execute(message.requestId); break; } + case "ConsoleEntry": case "EvoluError": { - postOrQueueTabOutput({ - type: "EvoluError", - error: leaderEvent.error, - }); + postTabOutput(message); break; } default: - exhaustiveCheck(leaderEvent); + exhaustiveCheck(message); } }; evoluPort.onMessage = (message) => { - console.log(message); + const queue = ensureMutationQueue(evoluName); + queue.push({ + changes: message.changes, + onCompleteIds: message.onCompleteIds, + subscribedQueries: message.subscribedQueries, + }); + processMutationQueue(evoluName); }; break; } @@ -172,3 +251,11 @@ export const initSharedWorker = return ok(stack.move()); }; + +interface QueuedMutation { + readonly changes: NonEmptyReadonlyArray; + readonly onCompleteIds: ReadonlyArray; + readonly subscribedQueries: ReadonlyArray; + requestId?: CallbackId; + ackPromise?: Promise; +} diff --git a/packages/common/src/local-first/Sync.ts b/packages/common/src/local-first/Sync.ts index 3cff5fb48..f6a34053e 100644 --- a/packages/common/src/local-first/Sync.ts +++ b/packages/common/src/local-first/Sync.ts @@ -800,54 +800,6 @@ const applyColumnChange = } }; -/** - * Attempts to apply quarantined messages that may now be valid after a schema - * update. Messages are quarantined when they reference tables or columns that - * don't exist in the current schema (e.g., from a newer app version). - */ -export const tryApplyQuarantinedMessages = - (deps: DbSchemaDep & SqliteDep) => (): void => { - const rows = deps.sqlite.exec<{ - readonly ownerId: OwnerIdBytes; - readonly timestamp: TimestampBytes; - readonly table: string; - readonly id: IdBytes; - readonly column: string; - readonly value: SqliteValue; - }>(sql` - select "ownerId", "timestamp", "table", "id", "column", "value" - from evolu_message_quarantine; - `); - - for (const row of rows.rows) { - if (!validateColumnValue(deps)(row.table, row.column, row.value)) - continue; - - applyColumnChange(deps)( - row.ownerId, - ownerIdBytesToOwnerId(row.ownerId), - row.table, - row.id, - idBytesToId(row.id), - row.column, - row.value, - row.timestamp, - ); - - { - deps.sqlite.exec(sql` - delete from evolu_message_quarantine - where - "ownerId" = ${row.ownerId} - and "timestamp" = ${row.timestamp} - and "table" = ${row.table} - and "id" = ${row.id} - and "column" = ${row.column}; - `); - } - } - }; - /** * TODO: Rework for the new owners API. * diff --git a/packages/common/test/local-first/Evolu.test.ts b/packages/common/test/local-first/Evolu.test.ts index d828a6af1..75c06d147 100644 --- a/packages/common/test/local-first/Evolu.test.ts +++ b/packages/common/test/local-first/Evolu.test.ts @@ -319,7 +319,7 @@ describe("createEvolu", () => { expect(dbWorkerMessages).toHaveLength(1); expect(dbWorkerMessages[0]).toEqual( expect.objectContaining({ - type: "init", + type: "Init", name: result.value.name, }), ); @@ -386,7 +386,7 @@ describe("mutations", () => { expect(evoluInputs).toHaveLength(1); expect(evoluInputs[0]).toEqual({ - type: "mutate", + type: "Mutate", changes: [ { table: "todo", @@ -426,7 +426,7 @@ describe("mutations", () => { expect(evoluInputs).toHaveLength(1); expect(evoluInputs[0]).toEqual({ - type: "mutate", + type: "Mutate", changes: [ { table: "todo", @@ -472,7 +472,7 @@ describe("mutations", () => { await Promise.resolve(); expect(evoluInputs).toHaveLength(1); - expect(evoluInputs[0]?.type).toBe("mutate"); + expect(evoluInputs[0]?.type).toBe("Mutate"); expect(evoluInputs[0]?.changes).toHaveLength(3); expect(evoluInputs[0]?.changes[1]?.isInsert).toBe(false); expect(evoluInputs[0]?.changes[2]?.isInsert).toBe(true); diff --git a/packages/common/test/local-first/Shared.test.ts b/packages/common/test/local-first/Shared.test.ts index a884fad74..fc72cc76a 100644 --- a/packages/common/test/local-first/Shared.test.ts +++ b/packages/common/test/local-first/Shared.test.ts @@ -7,7 +7,10 @@ import { type SharedWorkerInput, initSharedWorker, } from "../../src/local-first/Shared.js"; -import type { DbWorkerLeaderOutput } from "../../src/local-first/Db.js"; +import type { + DbWorkerLeaderInput, + DbWorkerLeaderOutput, +} from "../../src/local-first/Db.js"; import type { MutationChange } from "../../src/local-first/Schema.js"; import { testCreateConsole } from "../../src/Console.js"; import { testCreateRun } from "../../src/Test.js"; @@ -263,7 +266,7 @@ describe("initSharedWorker", () => { const evoluChannel = testCreateMessageChannel(); const leaderChannel = testCreateMessageChannel< - never, + DbWorkerLeaderInput, DbWorkerLeaderOutput >(); @@ -295,7 +298,7 @@ describe("initSharedWorker", () => { const evoluChannel = testCreateMessageChannel(); const leaderChannel = testCreateMessageChannel< - never, + DbWorkerLeaderInput, DbWorkerLeaderOutput >(); @@ -323,7 +326,7 @@ describe("initSharedWorker", () => { const evoluChannel = testCreateMessageChannel(); const leaderChannel = testCreateMessageChannel< - never, + DbWorkerLeaderInput, DbWorkerLeaderOutput >(); @@ -348,7 +351,7 @@ describe("initSharedWorker", () => { const evoluChannel = testCreateMessageChannel(); const leaderChannel = testCreateMessageChannel< - never, + DbWorkerLeaderInput, DbWorkerLeaderOutput >(); @@ -361,7 +364,7 @@ describe("initSharedWorker", () => { expect(() => { evoluChannel.port2.postMessage({ - type: "mutate", + type: "Mutate", changes: [{} as MutationChange], onCompleteIds: [], subscribedQueries: [], @@ -375,7 +378,7 @@ describe("initSharedWorker", () => { const evoluChannel = testCreateMessageChannel(); const leaderChannel = testCreateMessageChannel< - never, + DbWorkerLeaderInput, DbWorkerLeaderOutput >(); diff --git a/packages/nodejs/src/local-first/Relay.ts b/packages/nodejs/src/local-first/Relay.ts index c57a08e79..9e5bc582e 100644 --- a/packages/nodejs/src/local-first/Relay.ts +++ b/packages/nodejs/src/local-first/Relay.ts @@ -85,12 +85,12 @@ export const startRelay = createRelayStorageTables(deps); } - const storage = createRelaySqliteStorage(deps)({ - isOwnerWithinQuota, + const run = _run.addDeps({ + storage: createRelaySqliteStorage(deps)({ + isOwnerWithinQuota, + }), }); - const run = _run.addDeps({ storage }); - const server = createServer(); const wss = new WebSocketServer({ maxPayload: defaultProtocolMessageMaxSize, From b9bfdba08b4390add16000478cbe5e04b96f34f3 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 11:24:40 +0100 Subject: [PATCH 39/60] Added typed NativeMessagePort generics Typed NativeMessagePort with input and output phantom types to make port transfer wiring type-safe across platforms. Updated platform worker adapters to preserve these types at boundaries. (cherry picked from commit a2a448db16aa1b4ecbfd414a756648d7e8026f04) --- packages/common/src/Worker.ts | 17 +++++++++++++---- packages/react-native/src/Worker.ts | 14 +++++++------- packages/web/src/Worker.ts | 2 +- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/common/src/Worker.ts b/packages/common/src/Worker.ts index 4a5be1c40..8221ef50a 100644 --- a/packages/common/src/Worker.ts +++ b/packages/common/src/Worker.ts @@ -72,10 +72,10 @@ export interface MessagePort extends Disposable { onMessage: ((message: Output) => void) | null; /** The native underlying port. Use this only for transferring via postMessage. */ - readonly native: NativeMessagePort; + readonly native: NativeMessagePort; } -export type Transferable = NativeMessagePort | ArrayBuffer; +export type Transferable = NativeMessagePort | ArrayBuffer; /** * Opaque type for platform-specific native MessagePort. @@ -84,11 +84,20 @@ export type Transferable = NativeMessagePort | ArrayBuffer; * a wrapper. Ensures type-safe wiring between {@link MessagePort.native} and * {@link CreateMessagePort} without exposing platform details. */ -export type NativeMessagePort = Brand<"NativeMessagePort">; +export type NativeMessagePort< + Input = unknown, + Output = never, +> = Brand<"NativeMessagePort"> & { + readonly [nativeMessagePortInput]?: Input; + readonly [nativeMessagePortOutput]?: Output; +}; + +declare const nativeMessagePortInput: unique symbol; +declare const nativeMessagePortOutput: unique symbol; /** Factory function to create a {@link MessagePort} from a native port. */ export type CreateMessagePort = ( - nativePort: NativeMessagePort, + nativePort: NativeMessagePort, ) => MessagePort; export interface CreateMessagePortDep { diff --git a/packages/react-native/src/Worker.ts b/packages/react-native/src/Worker.ts index 843483d71..190ddcc7b 100644 --- a/packages/react-native/src/Worker.ts +++ b/packages/react-native/src/Worker.ts @@ -33,7 +33,7 @@ export const createWorker = ( }); }, onMessage: null, - native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port + native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port [Symbol.dispose]: () => { worker.onMessage = null; workerSelf?.[Symbol.dispose](); @@ -52,7 +52,7 @@ export const createWorker = ( worker.onMessage(message); }); }, - native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port + native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port [Symbol.dispose]: () => { if (workerSelf) workerSelf.onMessage = null; }, @@ -87,7 +87,7 @@ export const createSharedWorker = ( }); }, onMessage: null, - native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port + native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port [Symbol.dispose]: () => { clientPort.onMessage = null; }, @@ -104,7 +104,7 @@ export const createSharedWorker = ( }); }, onMessage: null, - native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port + native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port [Symbol.dispose]: () => { if (workerPort) workerPort.onMessage = null; }, @@ -160,7 +160,7 @@ export const createMessageChannel = (): MessageChannel< }); }, onMessage: null, - native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port + native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port [Symbol.dispose]: () => { port1.onMessage = null; }, @@ -177,7 +177,7 @@ export const createMessageChannel = (): MessageChannel< }); }, onMessage: null, - native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port + native: null as unknown as NativeMessagePort, // React Native runs in-process, no real native port [Symbol.dispose]: () => { port2.onMessage = null; }, @@ -200,6 +200,6 @@ export const createMessageChannel = (): MessageChannel< * wrapper, so this is a passthrough. */ export const createMessagePort = ( - nativePort: NativeMessagePort, + nativePort: NativeMessagePort, ): MessagePort => nativePort as unknown as MessagePort; diff --git a/packages/web/src/Worker.ts b/packages/web/src/Worker.ts index d86ef8307..566c70a9f 100644 --- a/packages/web/src/Worker.ts +++ b/packages/web/src/Worker.ts @@ -138,7 +138,7 @@ const wrap = ( else native.postMessage(message, [...transfer]); }, onMessage: null, - native: native as unknown as NativeMessagePort, + native: native as unknown as NativeMessagePort, [Symbol.dispose]: () => { native.onmessage = null; if (native instanceof globalThis.Worker) native.terminate(); From 73fd300e3a6b9da026664fc1eae1044f50593a85 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 11:25:05 +0100 Subject: [PATCH 40/60] Refactored local-first worker channel wiring Renamed init ports to port1 and port2, replaced broker naming with leader semantics, and scoped Evolu channel wiring for readability. Updated Db/Shared contracts and tests to match the clearer message flow. (cherry picked from commit 746ea844343efee9c8b8a05d2b74b7dec067f5a0) --- packages/common/src/local-first/Evolu.ts | 4 ++-- packages/common/src/local-first/Worker.ts | 13 ++++++++----- packages/common/test/local-first/Evolu.test.ts | 8 ++++---- packages/common/test/local-first/Worker.test.ts | 10 +++++----- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index f4d82db5b..f7eae0973 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -1285,8 +1285,8 @@ const createDbWorkerClient = ( { type: "InitEvolu", name, - port: channel.port1.native, - brokerPort: brokerChannel.port1.native, + port1: channel.port1.native, + port2: brokerChannel.port1.native, }, [channel.port1.native, brokerChannel.port1.native], ); diff --git a/packages/common/src/local-first/Worker.ts b/packages/common/src/local-first/Worker.ts index 453dc126f..aaa11ef2c 100644 --- a/packages/common/src/local-first/Worker.ts +++ b/packages/common/src/local-first/Worker.ts @@ -33,13 +33,16 @@ export interface EvoluWorkerDep { export type EvoluWorkerInput = | { readonly type: "InitTab"; - readonly port: NativeMessagePort; + readonly port: NativeMessagePort; } | { readonly type: "InitEvolu"; readonly name: SimpleName; - readonly port: NativeMessagePort; - readonly brokerPort: NativeMessagePort; + readonly port1: NativeMessagePort; + readonly port2: NativeMessagePort< + DbWorkerLeaderOutput, + DbWorkerLeaderInput + >; }; export type EvoluTabOutput = @@ -96,12 +99,12 @@ export const runEvoluWorkerScope = } case "InitEvolu": { const port = deps.createMessagePort( - message.port, + message.port1, ); const brokerPort = deps.createMessagePort< DbWorkerLeaderOutput, DbWorkerLeaderInput - >(message.brokerPort); + >(message.port2); deps.runDbWorkerPort({ name: message.name, port, brokerPort }); break; } diff --git a/packages/common/test/local-first/Evolu.test.ts b/packages/common/test/local-first/Evolu.test.ts index 9f6d64850..727ecc2f8 100644 --- a/packages/common/test/local-first/Evolu.test.ts +++ b/packages/common/test/local-first/Evolu.test.ts @@ -119,8 +119,8 @@ const createMockDeps = () => { | { type: "InitEvolu"; name: SimpleName; - port: unknown; - brokerPort: unknown; + port1: unknown; + port2: unknown; }, _transfer?: ReadonlyArray, ) => { @@ -130,11 +130,11 @@ const createMockDeps = () => { } if (message.type !== "InitEvolu") return; - const dbPort = message.port as MockPort< + const dbPort = message.port1 as MockPort< DbWorkerOutput, DbWorkerInput >; - const brokerPort = message.brokerPort as MockPort< + const brokerPort = message.port2 as MockPort< DbWorkerLeaderOutput, DbWorkerLeaderInput >; diff --git a/packages/common/test/local-first/Worker.test.ts b/packages/common/test/local-first/Worker.test.ts index b6e893dce..6a7c3256e 100644 --- a/packages/common/test/local-first/Worker.test.ts +++ b/packages/common/test/local-first/Worker.test.ts @@ -22,7 +22,7 @@ import type { const createTrackedPort = () => { let onMessage: ((message: Output) => void) | null = null; const sentMessages: Array = []; - const native = {} as NativeMessagePort; + const native = {} as NativeMessagePort; const port: MessagePort = { postMessage: (message) => { @@ -62,7 +62,7 @@ test("runEvoluWorkerScope forwards global errors to registered tab port", () => const tabPort = createTrackedPort(); const createMessagePort: CreateMessagePort = ( - nativePort: NativeMessagePort, + nativePort: NativeMessagePort, ): MessagePort => { if (nativePort === tabPort.native) return tabPort.port as unknown as MessagePort; @@ -97,7 +97,7 @@ test("runEvoluWorkerScope routes InitEvolu port to db worker runner", () => { const runDbWorkerPort = vi.fn(); const createMessagePort: CreateMessagePort = ( - nativePort: NativeMessagePort, + nativePort: NativeMessagePort, ): MessagePort => { if (nativePort === dbPort.native) return dbPort.port as unknown as MessagePort; @@ -116,8 +116,8 @@ test("runEvoluWorkerScope routes InitEvolu port to db worker runner", () => { workerConnection.emit({ type: "InitEvolu", name, - port: dbPort.native, - brokerPort: brokerPort.native, + port1: dbPort.native, + port2: brokerPort.native, }); expect(runDbWorkerPort).toHaveBeenCalledTimes(1); From 63c3ada8953af38fc404af0f4cc92b3992e2d369 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 11:28:42 +0100 Subject: [PATCH 41/60] Clarified SharedWorker communication comment Documented that Evolu instance communication is routed through SharedWorker as the central point for observability and resilience mechanisms such as retry and timeout. (cherry picked from commit 0b390f63ce31d2296867665b6449db33842b5e69) --- packages/common/src/local-first/Evolu.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index f7eae0973..726073009 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -1281,6 +1281,8 @@ const createDbWorkerClient = ( } }; + // All Evolu instance traffic goes through SharedWorker, which is our + // single control point for observability and resilience behavior. deps.evoluWorker.port.postMessage( { type: "InitEvolu", From e2a02ff20fad5fe9e2426bbd05971b18d06b7ebd Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 11:55:08 +0100 Subject: [PATCH 42/60] Simplified worker deps and forwarded DbWorker console entries Removed WorkerInitDep in favor of a single WorkerDeps contract, wired DbWorker console store output through the leader channel, and updated SharedWorker handling/tests to forward DbWorker console entries to tab outputs. (cherry picked from commit 0bbef0af912637f725a0d743a2d780fdd0024276) --- packages/common/src/Worker.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/common/src/Worker.ts b/packages/common/src/Worker.ts index 8221ef50a..4d993026f 100644 --- a/packages/common/src/Worker.ts +++ b/packages/common/src/Worker.ts @@ -6,6 +6,7 @@ import type { Brand } from "./Brand.js"; import type { GlobalErrorScope } from "./Error.js"; +import type { ConsoleDep, ConsoleStoreOutputEntryDep } from "./Console.js"; /** * Platform-agnostic Worker. @@ -104,6 +105,10 @@ export interface CreateMessagePortDep { readonly createMessagePort: CreateMessagePort; } +/** Common dependencies for worker entry points. */ +export type WorkerDeps = ConsoleDep & + ConsoleStoreOutputEntryDep & + CreateMessagePortDep; /** * Platform-agnostic MessageChannel. * From 57d7a982ca1fdd408598ed6cdb3c5c527be96421 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 14:34:12 +0100 Subject: [PATCH 43/60] Wired DbWorker init payload and worker output flow Added full DbWorker init inputs (consoleLevel, dbSchema, encryptionKey) and wired them from createEvolu through worker channels. Unified leader output typing with EvoluTabOutput, added typed EvoluError forwarding, and updated SharedWorker InitTab to receive/apply consoleLevel. Updated schema conversion typing to avoid external casts, wired sqlite driver in web Db worker run deps, and aligned SharedWorker tests with the new InitTab contract. (cherry picked from commit 57d23902edfa4062fbfe2904e2479881ae3f5ee5) --- packages/common/src/local-first/Evolu.ts | 6 +++++- packages/common/src/local-first/Schema.ts | 8 +++++--- packages/common/src/local-first/Worker.ts | 3 ++- packages/common/test/local-first/Evolu.test.ts | 2 +- packages/common/test/local-first/Worker.test.ts | 1 + 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index 726073009..334b0a3d1 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -654,7 +654,11 @@ export const createEvoluDeps = ( }; evoluWorker.port.postMessage( - { type: "InitTab", port: tabChannel.port1.native }, + { + type: "InitTab", + consoleLevel: console.getLevel(), + port: tabChannel.port1.native, + }, [tabChannel.port1.native], ); diff --git a/packages/common/src/local-first/Schema.ts b/packages/common/src/local-first/Schema.ts index a66dc3c0c..30ed7f13d 100644 --- a/packages/common/src/local-first/Schema.ts +++ b/packages/common/src/local-first/Schema.ts @@ -385,12 +385,14 @@ export const systemColumnsWithId = /*#__PURE__*/ readonly([ "id", ]); -export const evoluSchemaToDbSchema = ( - schema: EvoluSchema, +export const evoluSchemaToDbSchema = ( + schema: ValidateSchema extends never ? S : ValidateSchema, indexesConfig?: IndexesConfig, ): DbSchema => { + const validSchema = schema as EvoluSchema; + const tables = mapObject( - schema, + validSchema, (table) => new Set(Object.keys(table).filter((k) => k !== "id")), ); diff --git a/packages/common/src/local-first/Worker.ts b/packages/common/src/local-first/Worker.ts index aaa11ef2c..ae6f5b9b5 100644 --- a/packages/common/src/local-first/Worker.ts +++ b/packages/common/src/local-first/Worker.ts @@ -4,7 +4,7 @@ * @module */ -import type { ConsoleEntry } from "../Console.js"; +import type { ConsoleEntry, ConsoleLevel } from "../Console.js"; import { exhaustiveCheck } from "../Function.js"; import { ok } from "../Result.js"; import type { Task } from "../Task.js"; @@ -33,6 +33,7 @@ export interface EvoluWorkerDep { export type EvoluWorkerInput = | { readonly type: "InitTab"; + readonly consoleLevel: ConsoleLevel; readonly port: NativeMessagePort; } | { diff --git a/packages/common/test/local-first/Evolu.test.ts b/packages/common/test/local-first/Evolu.test.ts index 727ecc2f8..20e05c90b 100644 --- a/packages/common/test/local-first/Evolu.test.ts +++ b/packages/common/test/local-first/Evolu.test.ts @@ -115,7 +115,7 @@ const createMockDeps = () => { port: { postMessage: ( message: - | { type: "InitTab"; port: unknown } + | { type: "InitTab"; consoleLevel: string; port: unknown } | { type: "InitEvolu"; name: SimpleName; diff --git a/packages/common/test/local-first/Worker.test.ts b/packages/common/test/local-first/Worker.test.ts index 6a7c3256e..7e902261d 100644 --- a/packages/common/test/local-first/Worker.test.ts +++ b/packages/common/test/local-first/Worker.test.ts @@ -77,6 +77,7 @@ test("runEvoluWorkerScope forwards global errors to registered tab port", () => workerScope.onConnect?.(workerConnection.port); workerConnection.emit({ type: "InitTab", + consoleLevel: "debug", port: tabPort.native, }); From 1eca3d97fbcbbd4396414b91754fa0701b33230c Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 14:34:49 +0100 Subject: [PATCH 44/60] Wired sqlite driver into React Native deps Updated React Native shared dependency creation to require and pass CreateSqliteDriverDep into worker runtime deps. Updated the Expo SQLite export to provide createExpoSqliteDriver when creating Evolu deps. (cherry picked from commit d4f0e5f4142c86a0ca1a0d6d3600c61ad411219a) --- packages/react-native/src/exports/expo-sqlite.ts | 3 ++- packages/react-native/src/shared.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/react-native/src/exports/expo-sqlite.ts b/packages/react-native/src/exports/expo-sqlite.ts index 65d6949c9..d65b54bf5 100644 --- a/packages/react-native/src/exports/expo-sqlite.ts +++ b/packages/react-native/src/exports/expo-sqlite.ts @@ -8,17 +8,18 @@ import type { EvoluDeps } from "@evolu/common/local-first"; import * as Expo from "expo"; import { createEvoluDeps as createSharedEvoluDeps } from "../shared.js"; +import { createExpoSqliteDriver } from "../sqlite-drivers/createExpoSqliteDriver.js"; /** Creates Evolu dependencies for Expo. */ export const createEvoluDeps = (): EvoluDeps => createSharedEvoluDeps({ + createSqliteDriver: createExpoSqliteDriver, reloadApp: () => { void Expo.reloadAppAsync(); }, }); import { createExpoDeps } from "../createExpoDeps.js"; -import { createExpoSqliteDriver } from "../sqlite-drivers/createExpoSqliteDriver.js"; export const { evoluReactNativeDeps, localAuth } = createExpoDeps({ createSqliteDriver: createExpoSqliteDriver, diff --git a/packages/react-native/src/shared.ts b/packages/react-native/src/shared.ts index e85370d72..87ed5d8f2 100644 --- a/packages/react-native/src/shared.ts +++ b/packages/react-native/src/shared.ts @@ -1,6 +1,7 @@ import { createLocalAuth, createRandomBytes, + type CreateSqliteDriverDep, type LocalAuth, type ReloadAppDep, type SecureStorage, @@ -11,8 +12,9 @@ import { createEvoluDeps as createCommonEvoluDeps } from "@evolu/common/local-fi const randomBytes = createRandomBytes(); /** Creates Evolu dependencies for React Native. */ -export const createEvoluDeps = (deps: ReloadAppDep): EvoluDeps => - createCommonEvoluDeps(deps); +export const createEvoluDeps = ( + deps: ReloadAppDep & CreateSqliteDriverDep, +): EvoluDeps => createCommonEvoluDeps(deps); export const createSharedLocalAuth = ( secureStorage: SecureStorage, From 4daa253bebcd3e8d76f903c496be6fd66dcc9b92 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 22:27:34 +0100 Subject: [PATCH 45/60] Refactored SQLite APIs to throw-first semantics and aligned protocol/storage boundaries - Removed SqliteError from SQLite public APIs and switched infra failure handling to throws - Kept Result for domain/protocol outcomes and mapped boundary failures to protocol-level errors - Added Sqlite.transaction overload for callbacks returning Result or void - Simplified storage/query/schema/sync call sites and updated relay/client boundary behavior - Updated docs/changeset and tests across common, nodejs, and web to match new semantics - Follow-up cleanup removed redundant Result leftovers in storage helpers (cherry picked from commit f94006d713d2621742ef5956d0160932d1f5dbf9) --- .changeset/sqlite-refactor.md | 28 +- packages/common/src/Result.ts | 8 +- packages/common/src/Sqlite.ts | 175 +++++-------- packages/common/src/local-first/Error.ts | 2 - packages/common/src/local-first/Protocol.ts | 211 +++++++++------ packages/common/src/local-first/Query.ts | 13 +- packages/common/src/local-first/Relay.ts | 243 +++++++----------- packages/common/src/local-first/Schema.ts | 129 ++++------ packages/common/src/local-first/Storage.ts | 242 ++++++----------- packages/common/src/local-first/Sync.ts | 138 ++++------ packages/common/test/Sqlite.test.ts | 109 +++----- .../common/test/local-first/Protocol.test.ts | 6 +- .../common/test/local-first/Relay.test.ts | 17 +- .../common/test/local-first/Schema.test.ts | 76 ++---- .../common/test/local-first/Storage.test.ts | 36 +-- packages/common/test/local-first/_utils.ts | 13 +- packages/nodejs/src/local-first/Relay.ts | 45 ++-- packages/nodejs/test/Sqlite.test.ts | 25 +- packages/web/test/Sqlite.test.ts | 16 +- 19 files changed, 611 insertions(+), 921 deletions(-) diff --git a/.changeset/sqlite-refactor.md b/.changeset/sqlite-refactor.md index 022b6b1c4..d2fa7dacd 100644 --- a/.changeset/sqlite-refactor.md +++ b/.changeset/sqlite-refactor.md @@ -5,11 +5,31 @@ "@evolu/react-native": major --- -Refactored createSqlite and CreateSqliteDriver to use Task +Refactored SQLite integration to use Task and throw-first semantics -- Changed `createSqlite` to `Task` -- Changed `CreateSqliteDriver` to `Task` +- Changed `createSqlite` to `Task` +- Changed `CreateSqliteDriver` to `Task` +- Removed `SqliteError` from SQLite driver/task APIs +- Changed `Sqlite.exec` to return `SqliteExecResult` directly (no `Result<..., SqliteError>`) +- Changed `Sqlite.transaction` to support callbacks returning either `Result` or `void` (no `SqliteError` in the error channel) +- Changed `Sqlite.export` to return `Uint8Array` directly (no `Result<..., SqliteError>`) - Simplified `SqliteDriver.exec` by removing the `isMutation` parameter — the driver now determines read vs write internally - Replaced `options.memory` and `options.encryptionKey` with a discriminated `options.mode` field (`"memory"` | `"encrypted"`) -- Added 100% test coverage for better-sqlite3 and WASM SQLite drivers - Updated Expo and op-sqlite drivers to match the new API + +Why `SqliteError` was removed: + +- In Evolu, SQLite runs in-process. Failures are infrastructure-level and unrecoverable at the call site. +- Wrapping these failures as `Result` values did not create meaningful recovery paths; callers still had to fail. +- The correct behavior is to let such failures throw and surface them through platform `createRun` global handlers (web, nodejs, react-native), which report uncaught errors via Evolu `console.error`. +- Evolu also propagates `console.error` entries through its messaging layer into the shared `evoluError` global store, so app-level error subscriptions still receive these failures. + +Boundary handling: + +- At protocol boundaries (for example Protocol ↔ Storage), error handling remains explicit. +- Since storage implementations may throw, boundary code uses `try/catch`, logs with `console.error(error)`, and returns protocol-level outcomes. +- Protocol handles all thrown errors as boundary concerns, without coupling to SQLite-specific error types. + +Developer experience: + +- This change reduces boilerplate (`if (!result.ok)` branches and `SqliteError` plumbing), making infrastructure code less verbose and easier to follow. diff --git a/packages/common/src/Result.ts b/packages/common/src/Result.ts index a30b83a86..f99fa4237 100644 --- a/packages/common/src/Result.ts +++ b/packages/common/src/Result.ts @@ -182,9 +182,9 @@ import type { Typed } from "./Type.js"; * handleable at the app level. Group them in a union type like `AppError`: * * ```ts - * type AppError = SqliteError | SyncError | UnknownError; + * type AppError = TimestampError | SyncError | UnknownError; * - * interface SqliteError extends Typed<"SqliteError"> { + * interface TimestampError extends Typed<"TimestampError"> { * readonly error: UnknownError; * } * ``` @@ -197,9 +197,9 @@ import type { Typed } from "./Type.js"; * ```ts * const handleAppError = (error: AppError): void => { * switch (error.type) { - * case "SqliteError": + * case "TimestampError": * console.error(error.error.stack); // Log preserved stack trace - * showToast("Database error. Please restart the app."); + * showToast("Timestamp error. Your computer clock appears to be incorrect."); * break; * case "SyncError": * showToast("Sync failed. Retrying..."); diff --git a/packages/common/src/Sqlite.ts b/packages/common/src/Sqlite.ts index 05d9c374c..52f36013a 100644 --- a/packages/common/src/Sqlite.ts +++ b/packages/common/src/Sqlite.ts @@ -8,10 +8,8 @@ import type { Brand } from "./Brand.js"; import type { EncryptionKey } from "./Crypto.js"; import type { Eq } from "./Eq.js"; import { eqArrayNumber } from "./Eq.js"; -import type { UnknownError } from "./Error.js"; -import { createUnknownError } from "./Error.js"; import type { Result } from "./Result.js"; -import { err, ok, trySync } from "./Result.js"; +import { ok } from "./Result.js"; import type { Task } from "./Task.js"; import type { SimpleName, Typed } from "./Type.js"; import { Null, Number, String, Uint8Array, union } from "./Type.js"; @@ -26,25 +24,32 @@ import { Null, Number, String, Uint8Array, union } from "./Type.js"; export interface Sqlite extends Disposable { readonly exec: ( query: SqliteQuery, - ) => Result, SqliteError>; + ) => SqliteExecResult; /** * Executes a transaction, running the provided callback within a begin/commit - * block. If the callback returns an error, the transaction is rolled back. If - * the rollback fails, a {@link SqliteError} is returned with both the original - * error and rollbackError. + * block. + * + * If the callback returns a {@link Result} error, the transaction is rolled + * back and the error result is returned. + * + * If the callback returns `void`, the transaction is committed unless the + * callback throws. */ - readonly transaction: ( - callback: () => Result, - ) => Result; + readonly transaction: SqliteTransaction; - readonly export: () => Result; + readonly export: () => Uint8Array; } export interface SqliteDep { readonly sqlite: Sqlite; } +export interface SqliteTransaction { + (callback: () => Result): Result; + (callback: () => void): void; +} + /** Represents a SQL query to be executed on a {@link Sqlite} database. */ export interface SqliteQuery { readonly sql: SafeSql; @@ -106,12 +111,6 @@ export interface SqliteExecResult { readonly changes: number; } -/** Represents an error that occurred during a SQLite operation. */ -export interface SqliteError extends Typed<"SqliteError"> { - readonly error: UnknownError; - readonly rollbackError?: UnknownError; -} - /** * A row returned from a {@link Sqlite} query, mapping column names to * {@link SqliteValue}. @@ -158,7 +157,7 @@ export const createSqlite = ( name: SimpleName, options?: SqliteDriverOptions, - ): Task => + ): Task => async (run) => { const { createSqliteDriver } = run.deps; const console = run.deps.console.child("sql"); @@ -170,100 +169,59 @@ export const createSqlite = let isDisposed = false; - const rollback = ( - result: Result, - error: UnknownError, - ): Result => { - const rollbackResult = trySync(() => { - console.debug("rollback"); - driver.exec(sql`rollback;`); - }, createSqliteError); - if (!rollbackResult.ok) { - console.warn("rollback failed", rollbackResult.error); - return err({ - type: "SqliteError", - error, - rollbackError: rollbackResult.error.error, - }); - } - return result; - }; - return ok({ - exec: (query) => - trySync( - () => { - console.debug({ query }); - - const label = - query.options?.logQueryExecutionTime && - `SqliteQueryExecutionTime ${query.sql}`; - - if (label) console.time(label); - const result = driver.exec(query); - if (label) console.timeEnd(label); - - if (query.options?.logExplainQueryPlan) { - const result = driver.exec({ - ...query, - sql: `EXPLAIN QUERY PLAN ${query.sql}` as SafeSql, - }); - console.log("[logExplainQueryPlan]", query); - console.log( - drawSqliteQueryPlan( - result.rows as unknown as Array, - ), - ); - } - - console.debug({ result }); - return result as never; - }, - (error): SqliteError => ({ - type: "SqliteError", - error: createUnknownError(error), - }), - ), - - transaction: (callback) => { - const beginResult = trySync(() => { - console.debug("begin"); - driver.exec(sql`begin;`); - }, createSqliteError); - if (!beginResult.ok) return beginResult; - - const callbackResult = trySync(callback, createSqliteError); - if (!callbackResult.ok) { - return rollback(callbackResult, callbackResult.error.error); - } - - const callbackValue = callbackResult.value; - if (!callbackValue.ok) { - return rollback( - callbackValue, - createUnknownError(callbackValue.error), + exec: (query: SqliteQuery) => { + console.debug({ query }); + + const label = + query.options?.logQueryExecutionTime && + `SqliteQueryExecutionTime ${query.sql}`; + + if (label) console.time(label); + const result = driver.exec(query); + if (label) console.timeEnd(label); + + if (query.options?.logExplainQueryPlan) { + const result = driver.exec({ + ...query, + sql: `EXPLAIN QUERY PLAN ${query.sql}` as SafeSql, + }); + console.log("[logExplainQueryPlan]", query); + console.log( + drawSqliteQueryPlan( + result.rows as unknown as Array, + ), ); } - const commitResult = trySync(() => { - console.debug("commit"); - driver.exec(sql`commit;`); - }, createSqliteError); - if (!commitResult.ok) { - return rollback(commitResult, commitResult.error.error); - } - - return ok(callbackValue.value); + console.debug({ result }); + return result as SqliteExecResult; }, - export: () => - trySync( - () => driver.export(), - (error): SqliteError => ({ - type: "SqliteError", - error: createUnknownError(error), - }), - ), + transaction: ((callback: () => Result | void) => { + console.debug("begin"); + driver.exec(sql`begin;`); + + let shouldRollback = true; + using _rollback = { + [Symbol.dispose]: () => { + if (!shouldRollback) return; + console.debug("rollback"); + driver.exec(sql`rollback;`); + }, + }; + + const result = callback(); + if (result != null && !result.ok) return result; + + console.debug("commit"); + driver.exec(sql`commit;`); + shouldRollback = false; + + return result; + }) as SqliteTransaction, + + export: () => driver.export(), [Symbol.dispose]: () => { if (isDisposed) return; @@ -273,11 +231,6 @@ export const createSqlite = }); }; -const createSqliteError = (error: unknown): SqliteError => ({ - type: "SqliteError", - error: createUnknownError(error), -}); - interface SqliteQueryPlanRow { id: number; parent: number; diff --git a/packages/common/src/local-first/Error.ts b/packages/common/src/local-first/Error.ts index b3bf7429b..2efc7aa90 100644 --- a/packages/common/src/local-first/Error.ts +++ b/packages/common/src/local-first/Error.ts @@ -6,7 +6,6 @@ import type { DecryptWithXChaCha20Poly1305Error } from "../Crypto.js"; import type { UnknownError } from "../Error.js"; -import type { SqliteError } from "../Sqlite.js"; import type { ProtocolError } from "./Protocol.js"; import type { TimestampError } from "./Timestamp.js"; @@ -14,6 +13,5 @@ import type { TimestampError } from "./Timestamp.js"; export type EvoluError = | DecryptWithXChaCha20Poly1305Error | ProtocolError - | SqliteError | TimestampError | UnknownError; diff --git a/packages/common/src/local-first/Protocol.ts b/packages/common/src/local-first/Protocol.ts index ee6eb375e..1634324eb 100644 --- a/packages/common/src/local-first/Protocol.ts +++ b/packages/common/src/local-first/Protocol.ts @@ -181,6 +181,7 @@ import { Packr } from "msgpackr"; import { isNonEmptyArray, type NonEmptyReadonlyArray } from "../Array.js"; import { assert } from "../Assert.js"; import type { Brand } from "../Brand.js"; +import type { ConsoleDep } from "../Console.js"; import { type Buffer, bytesToHex, @@ -526,7 +527,7 @@ export const createProtocolMessageFromCrdtMessages = /** Creates a {@link ProtocolMessage} for sync. */ export const createProtocolMessageForSync = - (deps: StorageDep) => + (deps: StorageDep & ConsoleDep) => ( ownerId: OwnerId, subscriptionFlag?: SubscriptionFlag, @@ -538,8 +539,6 @@ export const createProtocolMessageForSync = const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId); const size = deps.storage.getSize(ownerIdBytes); - // Errors are handled by the storage. - if (size == null) return null; splitRange(deps)( ownerIdBytes, @@ -994,9 +993,14 @@ export const applyProtocolMessageAsClient = const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId); if (isNonEmptyArray(messages)) { - const result = await run(storage.writeMessages(ownerIdBytes, messages)); - // Errors are handled by the Storage. Here we just stop syncing. - if (!result.ok) return ok({ type: "NoResponse" }); + try { + const result = await run(storage.writeMessages(ownerIdBytes, messages)); + // Quota errors are handled by Storage; protocol just stops syncing. + if (!result.ok) return ok({ type: "NoResponse" }); + } catch (error) { + run.deps.console.error(error); + return ok({ type: "NoResponse" }); + } } // Now: No writeKey, no sync. @@ -1147,16 +1151,21 @@ export const applyProtocolMessageAsRelay = }); } - const result = await run(storage.writeMessages(ownerIdBytes, messages)); + try { + const result = await run(storage.writeMessages(ownerIdBytes, messages)); - if (!result.ok) { - const errorCode = - result.error.type === "StorageWriteError" - ? ProtocolErrorCode.WriteError - : ProtocolErrorCode.QuotaError; + if (!result.ok) { + const message = createProtocolMessageBuffer(ownerId, { + messageType: MessageType.Response, + errorCode: ProtocolErrorCode.QuotaError, + }).unwrap(); + return ok({ type: "Response", message }); + } + } catch (error) { + run.deps.console.error(error); const message = createProtocolMessageBuffer(ownerId, { messageType: MessageType.Response, - errorCode, + errorCode: ProtocolErrorCode.WriteError, }).unwrap(); return ok({ type: "Response", message }); } @@ -1267,16 +1276,20 @@ const decodeMessages = ( }; const sync = - (deps: StorageDep) => + (deps: StorageDep & ConsoleDep) => ( ranges: NonEmptyReadonlyArray, output: ProtocolMessageBuffer, ownerIdBytes: OwnerIdBytes, ): Result => { const outputInitialSize = output.getSize(); - - const storageSize = deps.storage.getSize(ownerIdBytes); - if (storageSize == null) return err(ProtocolErrorCode.SyncError); + let storageSize: NonNegativeInt; + try { + storageSize = deps.storage.getSize(ownerIdBytes); + } catch (error) { + deps.console.error(error); + return err(ProtocolErrorCode.SyncError); + } let prevUpperBound: RangeUpperBound | null = null; let prevIndex = NonNegativeInt.orThrow(0); @@ -1316,12 +1329,17 @@ const sync = const addFingerprintForRemainingRange = ( begin: NonNegativeInt, ): boolean => { - const fingerprint = deps.storage.fingerprint( - ownerIdBytes, - begin, - storageSize, - ); - if (!fingerprint) return false; + let fingerprint: Fingerprint; + try { + fingerprint = deps.storage.fingerprint( + ownerIdBytes, + begin, + storageSize, + ); + } catch (error) { + deps.console.error(error); + return false; + } // There is always a space for a ramaining range. output.addRange({ type: RangeType.Fingerprint, @@ -1335,13 +1353,18 @@ const sync = const currentUpperBound = range.upperBound; const lower = prevIndex; - let upper = deps.storage.findLowerBound( - ownerIdBytes, - prevIndex, - storageSize, - currentUpperBound, - ); - if (upper == null) return err(ProtocolErrorCode.SyncError); + let upper: NonNegativeInt; + try { + upper = deps.storage.findLowerBound( + ownerIdBytes, + prevIndex, + storageSize, + currentUpperBound, + ); + } catch (error) { + deps.console.error(error); + return err(ProtocolErrorCode.SyncError); + } switch (range.type) { case RangeType.Skip: { @@ -1350,12 +1373,17 @@ const sync = } case RangeType.Fingerprint: { - const ourFingerprint = deps.storage.fingerprint( - ownerIdBytes, - lower, - upper, - ); - if (ourFingerprint == null) return err(ProtocolErrorCode.SyncError); + let ourFingerprint: Fingerprint; + try { + ourFingerprint = deps.storage.fingerprint( + ownerIdBytes, + lower, + upper, + ); + } catch (error) { + deps.console.error(error); + return err(ProtocolErrorCode.SyncError); + } if (eqArrayNumber(range.fingerprint, ourFingerprint)) { skipRange(range); @@ -1386,52 +1414,61 @@ const sync = ); const ourTimestamps = createTimestampsBuffer(); - let cantReadDbChange = false as boolean; let exceeded = false as boolean; + let iterateFailed = false as boolean; + + try { + deps.storage.iterate( + ownerIdBytes, + lower, + upper, + (timestamp, index) => { + const timestampString = timestamp.join(); + const timestampBinary = timestampBytesToTimestamp(timestamp); + + let message: EncryptedCrdtMessage | null = null; + + if (timestampsWeNeed.has(timestampString)) { + timestampsWeNeed.delete(timestampString); + } else { + try { + message = { + timestamp: timestampBinary, + change: deps.storage.readDbChange( + ownerIdBytes, + timestamp, + ), + }; + } catch (error) { + deps.console.error(error); + iterateFailed = true; + return false; + } + } - deps.storage.iterate( - ownerIdBytes, - lower, - upper, - (timestamp, index) => { - const timestampString = timestamp.join(); - const timestampBinary = timestampBytesToTimestamp(timestamp); - - let message: EncryptedCrdtMessage | null = null; - - if (timestampsWeNeed.has(timestampString)) { - timestampsWeNeed.delete(timestampString); - } else { - const dbChange = deps.storage.readDbChange( - ownerIdBytes, - timestamp, - ); - if (dbChange == null) { - cantReadDbChange = true; + if ( + !output.canAddTimestampsRangeAndMessage( + ourTimestamps, + message, + ) + ) { + exceeded = true; + endBound = timestamp; + upper = index; return false; } - message = { - timestamp: timestampBinary, - change: dbChange, - }; - } - - if ( - !output.canAddTimestampsRangeAndMessage(ourTimestamps, message) - ) { - exceeded = true; - endBound = timestamp; - upper = index; - return false; - } - - ourTimestamps.add(timestampBinary); - if (message) output.addMessage(message); - return true; - }, - ); - if (cantReadDbChange) { + ourTimestamps.add(timestampBinary); + if (message) output.addMessage(message); + return true; + }, + ); + } catch (error) { + deps.console.error(error); + return err(ProtocolErrorCode.SyncError); + } + + if (iterateFailed) { return err(ProtocolErrorCode.SyncError); } @@ -1474,7 +1511,7 @@ const sync = }; const splitRange = - (deps: StorageDep) => + (deps: StorageDep & ConsoleDep) => ( ownerId: OwnerIdBytes, lower: NonNegativeInt, @@ -1515,13 +1552,17 @@ const splitRange = ...buckets.value.map((b) => NonNegativeInt.orThrow(b + lower)), ]; - const fingerprintRanges = deps.storage.fingerprintRanges( - ownerId, - fingerprintRangesBuckets, - upperBound, - ); - // Errors are handled by the storage. - if (fingerprintRanges == null) return; + let fingerprintRanges: ReadonlyArray; + try { + fingerprintRanges = deps.storage.fingerprintRanges( + ownerId, + fingerprintRangesBuckets, + upperBound, + ); + } catch (error) { + deps.console.error(error); + return; + } const rangesToUse = lower > 0 ? fingerprintRanges.slice(1) : fingerprintRanges; diff --git a/packages/common/src/local-first/Query.ts b/packages/common/src/local-first/Query.ts index d26ebbc37..70e4b9c49 100644 --- a/packages/common/src/local-first/Query.ts +++ b/packages/common/src/local-first/Query.ts @@ -10,12 +10,9 @@ import { createRandomBytes } from "../Crypto.js"; import type { Listener, Unsubscribe } from "../Listeners.js"; import type { ReadonlyRecord } from "../Object.js"; import { createRecord, isPlainObject, objectToEntries } from "../Object.js"; -import type { Result } from "../Result.js"; -import { ok } from "../Result.js"; import type { SafeSql, SqliteDep, - SqliteError, SqliteQuery, SqliteQueryOptions, SqliteRow, @@ -195,18 +192,14 @@ export const createGetQueryRowsCache = (): GetQueryRowsCache => { export const loadQueries = (deps: GetQueryRowsCacheDep & SqliteDep) => - ( - tabId: Id, - queries: ReadonlyArray, - ): Result, SqliteError> => { + (tabId: Id, queries: ReadonlyArray): ReadonlyArray => { const queriesRows = []; for (const query of queries) { const sqlQuery = deserializeQuery(query); const result = deps.sqlite.exec(sqlQuery); - if (!result.ok) return result; - queriesRows.push([query, result.value.rows] as const); + queriesRows.push([query, result.rows] as const); } const queryRowsCache = deps.getQueryRowsCache(tabId); @@ -225,7 +218,7 @@ export const loadQueries = ), }), ); - return ok(queryPatchesArray); + return queryPatchesArray; }; export interface QueryPatches { diff --git a/packages/common/src/local-first/Relay.ts b/packages/common/src/local-first/Relay.ts index 5acd5444f..62eaf1d52 100644 --- a/packages/common/src/local-first/Relay.ts +++ b/packages/common/src/local-first/Relay.ts @@ -11,10 +11,11 @@ import { mapArray, } from "../Array.js"; import type { TimingSafeEqualDep } from "../Crypto.js"; +import { assert } from "../Assert.js"; import { createInstances } from "../Instances.js"; import type { Result } from "../Result.js"; import { err, ok } from "../Result.js"; -import type { SqliteDep, SqliteError } from "../Sqlite.js"; +import type { SqliteDep } from "../Sqlite.js"; import { sql } from "../Sqlite.js"; import type { Mutex } from "../Task.js"; import { createMutex } from "../Task.js"; @@ -27,7 +28,6 @@ import { ownerIdBytesToOwnerId, } from "./Owner.js"; import type { - CreateBaseSqliteStorageConfig, EncryptedDbChange, SqliteStorageDeps, Storage, @@ -110,8 +110,8 @@ export interface Relay extends AsyncDisposable {} export const createRelaySqliteStorage = (deps: SqliteStorageDeps & TimingSafeEqualDep) => - (config: CreateBaseSqliteStorageConfig): Storage => { - const sqliteStorageBase = createBaseSqliteStorage(deps)(config); + (config: StorageConfig): Storage => { + const sqliteStorageBase = createBaseSqliteStorage(deps)(); /** Mutex instances cached per OwnerId to prevent concurrent writes. */ const ownerMutexes = createInstances(); @@ -136,42 +136,27 @@ export const createRelaySqliteStorage = where ownerId = ${ownerId}; `, ); - if (!selectWriteKey.ok) { - config.onStorageError(selectWriteKey.error); - return false; - } - - const { rows } = selectWriteKey.value; + const { rows } = selectWriteKey; if (isNonEmptyArray(rows)) { return deps.timingSafeEqual(rows[0].writeKey, writeKey); } - const insertWriteKey = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` insert into evolu_writeKey (ownerId, writeKey) values (${ownerId}, ${writeKey}); `); - if (!insertWriteKey.ok) { - config.onStorageError(insertWriteKey.error); - return false; - } return true; }, setWriteKey: (ownerId, writeKey) => { - const upsertWriteKey = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` insert into evolu_writeKey (ownerId, writeKey) values (${ownerId}, ${writeKey}) on conflict (ownerId) do update set writeKey = excluded.writeKey; `); - if (!upsertWriteKey.ok) { - config.onStorageError(upsertWriteKey.error); - return false; - } - - return true; }, writeMessages: (ownerIdBytes, messages) => async (run) => { @@ -184,109 +169,98 @@ export const createRelaySqliteStorage = const result = await run( ownerMutexes .ensure(ownerId, createMutex) - .withLock( - async (): Promise< - Result - > => { - const existingTimestampsResult = - sqliteStorageBase.getExistingTimestamps( - ownerIdBytes, - mapArray(messagesWithTimestampBytes, (m) => m.timestamp), - ); - if (!existingTimestampsResult.ok) - return existingTimestampsResult; - - const existingTimestampsSet = new Set( - existingTimestampsResult.value.map((t) => t.toString()), - ); - const newMessages = filterArray( - messagesWithTimestampBytes, - (m) => !existingTimestampsSet.has(m.timestamp.toString()), - ); - - // Nothing to write - if (!isNonEmptyArray(newMessages)) { - return ok(); - } - - const usage = getOwnerUsage(deps)( + .withLock(async (): Promise> => { + const existingTimestampsResult = + sqliteStorageBase.getExistingTimestamps( ownerIdBytes, - firstInArray(newMessages).timestamp, + mapArray(messagesWithTimestampBytes, (m) => m.timestamp), ); - if (!usage.ok) return usage; - const { storedBytes } = usage.value; + const existingTimestampsSet = new Set( + existingTimestampsResult.map((t) => t.toString()), + ); + const newMessages = filterArray( + messagesWithTimestampBytes, + (m) => !existingTimestampsSet.has(m.timestamp.toString()), + ); + + // Nothing to write + if (!isNonEmptyArray(newMessages)) { + return ok(); + } + + const usage = getOwnerUsage(deps)( + ownerIdBytes, + firstInArray(newMessages).timestamp, + ); + if (!usage.ok) return usage; + + const { storedBytes } = usage.value; + + const incomingBytes = newMessages.reduce( + (sum, m) => sum + m.change.length, + 0, + ); + const newStoredBytes = PositiveInt.orThrow( + (storedBytes ?? 0) + incomingBytes, + ); + + const quotaResult = config.isOwnerWithinQuota( + ownerId, + newStoredBytes, + ); + const isWithinQuota = isPromiseLike(quotaResult) + ? await quotaResult + : quotaResult; + if (!isWithinQuota) { + return err({ type: "StorageQuotaError", ownerId }); + } + + let { firstTimestamp, lastTimestamp } = usage.value; + + return deps.sqlite.transaction(() => { + for (const { timestamp, change } of newMessages) { + let strategy; + [strategy, firstTimestamp, lastTimestamp] = + getTimestampInsertStrategy( + timestamp, + firstTimestamp, + lastTimestamp, + ); + + { + sqliteStorageBase.insertTimestamp( + ownerIdBytes, + timestamp, + strategy, + ); + } - const incomingBytes = newMessages.reduce( - (sum, m) => sum + m.change.length, - 0, - ); - const newStoredBytes = PositiveInt.orThrow( - (storedBytes ?? 0) + incomingBytes, - ); + { + deps.sqlite.exec(sql` + insert into evolu_message + ("ownerId", "timestamp", "change") + values (${ownerIdBytes}, ${timestamp}, ${change}) + on conflict do nothing; + `); + } + } - const quotaResult = config.isOwnerWithinQuota( - ownerId, + updateOwnerUsage(deps)( + ownerIdBytes, newStoredBytes, + firstTimestamp, + lastTimestamp, ); - const isWithinQuota = isPromiseLike(quotaResult) - ? await quotaResult - : quotaResult; - if (!isWithinQuota) { - return err({ type: "StorageQuotaError", ownerId }); - } - let { firstTimestamp, lastTimestamp } = usage.value; - - return deps.sqlite.transaction(() => { - for (const { timestamp, change } of newMessages) { - let strategy; - [strategy, firstTimestamp, lastTimestamp] = - getTimestampInsertStrategy( - timestamp, - firstTimestamp, - lastTimestamp, - ); - - { - const result = sqliteStorageBase.insertTimestamp( - ownerIdBytes, - timestamp, - strategy, - ); - if (!result.ok) return result; - } - - { - const result = deps.sqlite.exec(sql` - insert into evolu_message - ("ownerId", "timestamp", "change") - values (${ownerIdBytes}, ${timestamp}, ${change}) - on conflict do nothing; - `); - if (!result.ok) return result; - } - } - - return updateOwnerUsage(deps)( - ownerIdBytes, - newStoredBytes, - firstTimestamp, - lastTimestamp, - ); - }); - }, - ), + return ok(); + }); + }), ); - if (!result.ok && result.error.type !== "AbortError") { - switch (result.error.type) { - case "SqliteError": - config.onStorageError(result.error); - return err({ type: "StorageWriteError", ownerId }); - case "StorageQuotaError": - return err({ type: "StorageQuotaError", ownerId }); - } + if (!result.ok) { + if (result.error.type === "AbortError") return ok(); + return err({ type: "StorageQuotaError", ownerId }); } return ok(); @@ -300,51 +274,35 @@ export const createRelaySqliteStorage = from evolu_message where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}; `); - if (!result.ok) { - config.onStorageError(result.error); - return null; - } - return result.value.rows[0]?.change; + const row = result.rows[0]; + assert(row, "Every timestamp must have a change"); + return row.change; }, deleteOwner: (ownerId) => { - const transactionResult = deps.sqlite.transaction(() => { - const deleteWriteKey = deps.sqlite.exec(sql` + deps.sqlite.transaction(() => { + deps.sqlite.exec(sql` delete from evolu_writeKey where ownerId = ${ownerId}; `); - if (!deleteWriteKey.ok) return deleteWriteKey; - const deleteMessages = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from evolu_message where ownerId = ${ownerId}; `); - if (!deleteMessages.ok) return deleteMessages; - const deleteUsage = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from evolu_usage where ownerId = ${ownerId}; `); - if (!deleteUsage.ok) return deleteUsage; - const deleteBaseOwner = sqliteStorageBase.deleteOwner(ownerId); - if (!deleteBaseOwner) return err(null); + sqliteStorageBase.deleteOwner(ownerId); return ok(); }); - - if (!transactionResult.ok) { - if (transactionResult.error) - config.onStorageError(transactionResult.error); - return false; - } - - return true; }, }; }; -export const createRelayStorageTables = ( - deps: SqliteDep, -): Result => { +export const createRelayStorageTables = (deps: SqliteDep): void => { for (const query of [ sql` create table evolu_writeKey ( @@ -365,9 +323,6 @@ export const createRelayStorageTables = ( strict; `, ]) { - const result = deps.sqlite.exec(query); - if (!result.ok) return result; + deps.sqlite.exec(query); } - - return ok(); }; diff --git a/packages/common/src/local-first/Schema.ts b/packages/common/src/local-first/Schema.ts index 30ed7f13d..10b741c64 100644 --- a/packages/common/src/local-first/Schema.ts +++ b/packages/common/src/local-first/Schema.ts @@ -12,29 +12,27 @@ import { mapObject, type ReadonlyRecord, } from "../Object.js"; -import { ok, type Result } from "../Result.js"; import { type SafeSql, + sql, SqliteBoolean, type SqliteDep, - type SqliteError, type SqliteQuery, type SqliteQueryOptions, - type SqliteValue, - sql, + SqliteValue, } from "../Sqlite.js"; import type { InferType } from "../Type.js"; import { array, DateIso, type Id, - type IdBytes, + IdBytes, nullOr, object, record, + set, type StandardSchemaV1, String, - set, } from "../Type.js"; import type { Simplify } from "../Types.js"; import type { AppOwner } from "./Owner.js"; @@ -42,7 +40,7 @@ import { OwnerId } from "./Owner.js"; import type { Query, Row } from "./Query.js"; import { serializeQuery } from "./Query.js"; import type { CrdtMessage, DbChange } from "./Storage.js"; -import type { TimestampBytes } from "./Timestamp.js"; +import { TimestampBytes } from "./Timestamp.js"; /** Any {@link StandardSchemaV1}. */ export type AnyStandardSchemaV1 = StandardSchemaV1; @@ -448,81 +446,68 @@ export const createQueryBuilder = /** Get the current database schema by reading SQLite metadata. */ export const getDbSchema = (deps: SqliteDep) => - ({ - allIndexes = false, - }: { - allIndexes?: boolean; - } = {}): Result => { + ({ allIndexes = false }: { allIndexes?: boolean } = {}): DbSchema => { const tables = createRecord>(); - const tableAndColumnInfoRows = deps.sqlite.exec(sql` - select - sqlite_master.name as tableName, - table_info.name as columnName - from - sqlite_master - join pragma_table_info(sqlite_master.name) as table_info; - `); - - if (!tableAndColumnInfoRows.ok) return tableAndColumnInfoRows; - - tableAndColumnInfoRows.value.rows.forEach((row) => { - const { tableName, columnName } = row as unknown as { + const tableAndColumnInfoRows = deps.sqlite.exec<{ tableName: string; columnName: string; - }; - (tables[tableName] ??= new Set()).add(columnName); + }>(sql` + select + sqlite_master.name as tableName, + table_info.name as columnName + from + sqlite_master + join pragma_table_info(sqlite_master.name) as table_info; + `); + + tableAndColumnInfoRows.rows.forEach(({ tableName, columnName }) => { + (tables[tableName] ??= new Set()).add(columnName); + }); + + const indexesRows = deps.sqlite.exec<{ name: string; sql: string | null }>( + allIndexes + ? sql` + select name, sql + from sqlite_master + where type = 'index' and name not like 'sqlite_%'; + ` + : sql` + select name, sql + from sqlite_master + where + type = 'index' + and name not like 'sqlite_%' + and name not like 'evolu_%'; + `, + ); + + const indexes = indexesRows.rows.flatMap((row): Array => { + if (row.sql == null) return []; + return [ + { + name: row.name, + /** + * SQLite returns "CREATE INDEX" for "create index" for some reason. + * Other keywords remain unchanged. We have to normalize the casing for + * {@link indexesAreEqual} manually. + */ + sql: row.sql + .replace("CREATE INDEX", "create index") + .replace("CREATE UNIQUE INDEX", "create unique index"), + }, + ]; }); - const indexesRows = deps.sqlite.exec( - allIndexes - ? sql` - select name, sql - from sqlite_master - where type = 'index' and name not like 'sqlite_%'; - ` - : sql` - select name, sql - from sqlite_master - where - type = 'index' - and name not like 'sqlite_%' - and name not like 'evolu_%'; - `, - ); - - if (!indexesRows.ok) return indexesRows; - - const indexes = indexesRows.value.rows.map( - (row): DbIndex => ({ - name: row.name as string, - /** - * SQLite returns "CREATE INDEX" for "create index" for some reason. - * Other keywords remain unchanged. We have to normalize the casing for - * {@link indexesAreEqual} manually. - */ - sql: (row.sql as string) - .replace("CREATE INDEX", "create index") - .replace("CREATE UNIQUE INDEX", "create unique index"), - }), - ); - - return ok({ tables, indexes }); + return { tables, indexes }; }; export const ensureDbSchema = (deps: SqliteDep) => - ( - newSchema: DbSchema, - currentSchema?: DbSchema, - ): Result => { + (newSchema: DbSchema, currentSchema?: DbSchema): void => { const queries: Array = []; - if (!currentSchema) { - const dbSchema = getDbSchema(deps)(); - if (!dbSchema.ok) return dbSchema; - currentSchema = dbSchema.value; - } + currentSchema ??= getDbSchema(deps)(); for (const [tableName, newColumns] of Object.entries(newSchema.tables)) { const currentColumns = getProperty(currentSchema.tables, tableName); @@ -563,10 +548,8 @@ export const ensureDbSchema = }); for (const query of queries) { - const result = deps.sqlite.exec(query); - if (!result.ok) return result; + deps.sqlite.exec(query); } - return ok(); }; // https://kysely.dev/docs/recipes/splitting-query-building-and-execution diff --git a/packages/common/src/local-first/Storage.ts b/packages/common/src/local-first/Storage.ts index 16be1c594..928893700 100644 --- a/packages/common/src/local-first/Storage.ts +++ b/packages/common/src/local-first/Storage.ts @@ -14,8 +14,8 @@ import { decrement } from "../Number.js"; import type { RandomDep } from "../Random.js"; import type { Result } from "../Result.js"; import { err, ok } from "../Result.js"; -import type { SqliteDep, SqliteError } from "../Sqlite.js"; -import { SqliteValue, sql } from "../Sqlite.js"; +import type { SqliteDep } from "../Sqlite.js"; +import { sql, SqliteValue } from "../Sqlite.js"; import type { Task } from "../Task.js"; import type { InferType, Int64String, Typed, TypeError } from "../Type.js"; import { @@ -30,19 +30,10 @@ import { String, } from "../Type.js"; import type { Awaitable } from "../Types.js"; -import type { - Owner, - OwnerError, - OwnerId, - OwnerIdBytes, - OwnerWriteKey, -} from "./Owner.js"; +import type { Owner, OwnerError, OwnerIdBytes } from "./Owner.js"; +import { OwnerId, OwnerWriteKey } from "./Owner.js"; import { systemColumnsWithId } from "./Schema.js"; -import { - orderTimestampBytes, - type Timestamp, - type TimestampBytes, -} from "./Timestamp.js"; +import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js"; export interface StorageConfig { /** @@ -98,13 +89,13 @@ export interface StorageConfig { * itself remains synchronous. */ export interface Storage { - readonly getSize: (ownerId: OwnerIdBytes) => NonNegativeInt | null; + readonly getSize: (ownerId: OwnerIdBytes) => NonNegativeInt; readonly fingerprint: ( ownerId: OwnerIdBytes, begin: NonNegativeInt, end: NonNegativeInt, - ) => Fingerprint | null; + ) => Fingerprint; /** * Computes fingerprints with their upper bounds in one call. @@ -117,14 +108,14 @@ export interface Storage { ownerId: OwnerIdBytes, buckets: ReadonlyArray, upperBound?: RangeUpperBound, - ) => ReadonlyArray | null; + ) => ReadonlyArray; readonly findLowerBound: ( ownerId: OwnerIdBytes, begin: NonNegativeInt, end: NonNegativeInt, upperBound: RangeUpperBound, - ) => NonNegativeInt | null; + ) => NonNegativeInt; readonly iterate: ( ownerId: OwnerIdBytes, @@ -147,7 +138,7 @@ export interface Storage { readonly setWriteKey: ( ownerId: OwnerIdBytes, writeKey: OwnerWriteKey, - ) => boolean; + ) => void; /** * Write encrypted {@link CrdtMessage}s to storage. @@ -158,35 +149,25 @@ export interface Storage { readonly writeMessages: ( ownerIdBytes: OwnerIdBytes, messages: NonEmptyReadonlyArray, - ) => Task; + ) => Task; /** Read encrypted {@link DbChange}s from storage. */ readonly readDbChange: ( ownerId: OwnerIdBytes, timestamp: TimestampBytes, - ) => EncryptedDbChange | null; + ) => EncryptedDbChange; - /** - * Delete all data for the given {@link Owner}. - * - * Returns `true` on success, `false` on failure. - */ - readonly deleteOwner: (ownerId: OwnerIdBytes) => boolean; + /** Delete all data for the given {@link Owner}. */ + readonly deleteOwner: (ownerId: OwnerIdBytes) => void; } export interface StorageDep { readonly storage: Storage; } -/** Error indicating a serious write failure. */ -export interface StorageWriteError - extends OwnerError, - Typed<"StorageWriteError"> {} - /** Error when storage or billing quota is exceeded. */ export interface StorageQuotaError - extends OwnerError, - Typed<"StorageQuotaError"> {} + extends OwnerError, Typed<"StorageQuotaError"> {} /** * A cryptographic hash used for efficiently comparing collections of @@ -282,8 +263,7 @@ export const ValidDbChangeValues = /*#__PURE__*/ brand( ); export type ValidDbChangeValues = typeof ValidDbChangeValues.Type; -export interface ValidDbChangeValuesError - extends TypeError<"ValidDbChangeValues"> { +export interface ValidDbChangeValuesError extends TypeError<"ValidDbChangeValues"> { readonly invalidColumns: ReadonlyArray; } @@ -333,22 +313,21 @@ export interface DbChange extends InferType {} * users, and when it goes down, nothing happens, because it will be * synchronized later. */ -export interface BaseSqliteStorage - extends Pick< - Storage, - | "getSize" - | "fingerprint" - | "fingerprintRanges" - | "findLowerBound" - | "iterate" - | "deleteOwner" - > { +export interface BaseSqliteStorage extends Pick< + Storage, + | "getSize" + | "fingerprint" + | "fingerprintRanges" + | "findLowerBound" + | "iterate" + | "deleteOwner" +> { /** Inserts a timestamp for an owner into the skiplist-based storage. */ readonly insertTimestamp: ( ownerId: OwnerIdBytes, timestamp: TimestampBytes, strategy: StorageInsertTimestampStrategy, - ) => Result; + ) => void; /** * Efficiently checks which timestamps already exist in the database using a @@ -357,7 +336,7 @@ export interface BaseSqliteStorage readonly getExistingTimestamps: ( ownerIdBytes: OwnerIdBytes, timestampsBytes: NonEmptyReadonlyArray, - ) => Result, SqliteError>; + ) => ReadonlyArray; } export interface BaseSqliteStorageDep { @@ -366,10 +345,6 @@ export interface BaseSqliteStorageDep { export type SqliteStorageDeps = RandomDep & SqliteDep; -export interface CreateBaseSqliteStorageConfig extends StorageConfig { - onStorageError: (error: SqliteError) => void; -} - /** * Creates a {@link BaseSqliteStorage} implementation. * @@ -384,15 +359,14 @@ export interface CreateBaseSqliteStorageConfig extends StorageConfig { * environments yet, the stateless design should work well across them. */ export const createBaseSqliteStorage = - (deps: SqliteStorageDeps) => - (config: CreateBaseSqliteStorageConfig): BaseSqliteStorage => ({ + (deps: SqliteStorageDeps) => (): BaseSqliteStorage => ({ insertTimestamp: ( ownerId: OwnerIdBytes, timestamp: TimestampBytes, strategy: StorageInsertTimestampStrategy, ) => { const level = randomSkiplistLevel(deps); - return insertTimestamp(deps)(ownerId, timestamp, level, strategy); + insertTimestamp(deps)(ownerId, timestamp, level, strategy); }, getExistingTimestamps: (ownerIdBytes, timestampsBytes) => { @@ -420,47 +394,20 @@ export const createBaseSqliteStorage = on t.ownerId = ${ownerIdBytes} and s.timestampBytes = t.t; `); - if (!result.ok) return result; - - return ok(result.value.rows.map((row) => row.timestampBytes)); + return result.rows.map((row) => row.timestampBytes); }, - getSize: (ownerId) => { - const size = getSize(deps)(ownerId); - if (!size.ok) { - config.onStorageError(size.error); - return null; - } - return size.value; - }, + getSize: getSize(deps), fingerprint: (ownerId, begin, end) => { assertBeginEnd(begin, end); - const result = fingerprint(deps)(ownerId, begin, end); - if (!result.ok) { - config.onStorageError(result.error); - return null; - } - return result.value; + return fingerprint(deps)(ownerId, begin, end); }, - fingerprintRanges: (ownerId, buckets, upperBound) => { - const ranges = fingerprintRanges(deps)(ownerId, buckets, upperBound); - if (!ranges.ok) { - config.onStorageError(ranges.error); - return null; - } - return ranges.value; - }, + fingerprintRanges: fingerprintRanges(deps), - findLowerBound: (ownerId, begin, end, upperBound) => { - const lowerBound = findLowerBound(deps)(ownerId, begin, end, upperBound); - if (!lowerBound.ok) { - config.onStorageError(lowerBound.error); - return null; - } - return lowerBound.value; - }, + findLowerBound: (ownerId, begin, end, upperBound) => + findLowerBound(deps)(ownerId, begin, end, upperBound), iterate: (ownerId, begin, end, callback) => { assertBeginEnd(begin, end); @@ -469,12 +416,8 @@ export const createBaseSqliteStorage = // This is much faster than SQL limit with offset. const first = getTimestampByIndex(deps)(ownerId, begin); - if (!first.ok) { - config.onStorageError(first.error); - return; - } - if (!callback(first.value, begin)) return; + if (!callback(first, begin)) return; if (length === 1) return; /** @@ -490,30 +433,21 @@ export const createBaseSqliteStorage = const result = deps.sqlite.exec<{ t: TimestampBytes }>(sql` select t from evolu_timestamp - where ownerId = ${ownerId} and t > ${first.value} + where ownerId = ${ownerId} and t > ${first} order by t limit ${length - 1}; `); - if (!result.ok) { - config.onStorageError(result.error); - return; - } - for (let i = 0; i < result.value.rows.length; i++) { + for (let i = 0; i < result.rows.length; i++) { const index = NonNegativeInt.orThrow(begin + 1 + i); - if (!callback(result.value.rows[i].t, index)) return; + if (!callback(result.rows[i].t, index)) return; } }, deleteOwner: (ownerId) => { - const result = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from evolu_timestamp where ownerId = ${ownerId}; `); - if (!result.ok) { - config.onStorageError(result.error); - return false; - } - return true; }, }); @@ -521,9 +455,7 @@ const assertBeginEnd = (begin: NonNegativeInt, end: NonNegativeInt) => { assert(begin <= end, "invalid begin or end"); }; -export const createBaseSqliteStorageTables = ( - deps: SqliteDep, -): Result => { +export const createBaseSqliteStorageTables = (deps: SqliteDep): void => { for (const query of [ /** * Creates the `evolu_timestamp` table for storing timestamps of multiple @@ -585,10 +517,8 @@ export const createBaseSqliteStorageTables = ( strict; `, ]) { - const result = deps.sqlite.exec(query); - if (!result.ok) return result; + deps.sqlite.exec(query); } - return ok(); }; export type StorageInsertTimestampStrategy = "append" | "prepend" | "insert"; @@ -638,7 +568,7 @@ const insertTimestamp = timestamp: TimestampBytes, level: PositiveInt, strategy: StorageInsertTimestampStrategy, - ): Result => { + ): void => { const [h1, h2] = fingerprintToSqliteFingerprint( timestampBytesToFingerprint(timestamp), ); @@ -1151,11 +1081,8 @@ const insertTimestamp = } for (const query of queries) { - const result = deps.sqlite.exec(query); - if (!result.ok) return result; + deps.sqlite.exec(query); } - - return ok(); }; export const timestampBytesToFingerprint = ( @@ -1245,7 +1172,7 @@ const sqliteFingerprintToFingerprint = ([ const getSize = (deps: SqliteDep) => - (ownerId: OwnerIdBytes): Result => { + (ownerId: OwnerIdBytes): NonNegativeInt => { const result = deps.sqlite.exec<{ size: NonNegativeInt }>(sql.prepared` with ml(ml) as ( @@ -1281,8 +1208,7 @@ const getSize = from sc; `); - if (!result.ok) return result; - return ok(result.value.rows[0].size); + return result.rows[0].size; }; const findLowerBound = @@ -1292,11 +1218,11 @@ const findLowerBound = begin: NonNegativeInt, end: NonNegativeInt, upperBound: RangeUpperBound, - ): Result => { + ): NonNegativeInt => { assertBeginEnd(begin, end); if (end === 0 || begin === end || upperBound === InfiniteUpperBound) { - return ok(end); + return end; } const result = deps.sqlite.exec<{ @@ -1308,25 +1234,19 @@ const findLowerBound = order by t limit 1; `); - if (!result.ok) return result; - if (result.value.rows.length === 0) { - return ok(end); + if (result.rows.length === 0) { + return end; } - const count = getTimestampCount(deps)(ownerId, result.value.rows[0].t); - if (!count.ok) return count; - + const count = getTimestampCount(deps)(ownerId, result.rows[0].t); // `decrement` converts a count to an index. - return ok(NonNegativeInt.orThrow(decrement(count.value))); + return NonNegativeInt.orThrow(decrement(count)); }; const getTimestampCount = (deps: SqliteDep) => - ( - ownerId: OwnerIdBytes, - timestamp: TimestampBytes, - ): Result => { + (ownerId: OwnerIdBytes, timestamp: TimestampBytes): PositiveInt => { const result = deps.sqlite.exec<{ count: PositiveInt; }>(sql.prepared` @@ -1371,8 +1291,7 @@ const getTimestampCount = from sc; `); - if (!result.ok) return result; - return ok(result.value.rows[0].count); + return result.rows[0].count; }; /** @@ -1385,22 +1304,18 @@ const fingerprint = ownerId: OwnerIdBytes, begin: NonNegativeInt, end: NonNegativeInt, - ): Result => { + ): Fingerprint => { // There is no need to fingerprint an empty range. if (end - begin === 0) { - return ok(zeroFingerprint); + return zeroFingerprint; } if (begin === 0) { - const result = fingerprintRanges(deps)(ownerId, [end]); - if (!result.ok) return result; - return ok(result.value[0].fingerprint); + return fingerprintRanges(deps)(ownerId, [end])[0].fingerprint; } // We should have a param to skip the first result. - const result = fingerprintRanges(deps)(ownerId, [begin, end]); - if (!result.ok) return result; - return ok(result.value[1].fingerprint); + return fingerprintRanges(deps)(ownerId, [begin, end])[1].fingerprint; }; /** @@ -1417,7 +1332,7 @@ const fingerprintRanges = ownerId: OwnerIdBytes, buckets: ReadonlyArray, upperBound: RangeUpperBound = InfiniteUpperBound, - ): Result, SqliteError> => { + ): ReadonlyArray => { const bucketsJson = JSON.stringify(buckets); const result = deps.sqlite.exec<{ @@ -1535,12 +1450,9 @@ const fingerprintRanges = from c3; `); - if (!result.ok) return result; - - const fingerprintRanges = result.value.rows.map( + const fingerprintRanges = result.rows.map( (row, i, arr): FingerprintRange => ({ type: RangeType.Fingerprint, - // biome-ignore lint/style/noNonNullAssertion: Guaranteed by logic upperBound: i === arr.length - 1 ? upperBound : row.b!, fingerprint: sqliteFingerprintToFingerprint([ row.h1, @@ -1549,7 +1461,7 @@ const fingerprintRanges = }), ); - return ok(fingerprintRanges); + return fingerprintRanges; }; // XOR in SQLite @@ -1557,10 +1469,7 @@ const x = (a: string, b: string) => sql.raw(`(${a} | ${b}) - (${a} & ${b})`); export const getTimestampByIndex = (deps: SqliteDep) => - ( - ownerId: OwnerIdBytes, - index: NonNegativeInt, - ): Result => { + (ownerId: OwnerIdBytes, index: NonNegativeInt): TimestampBytes => { const result = deps.sqlite.exec<{ readonly pt: TimestampBytes; }>(sql.prepared` @@ -1635,8 +1544,7 @@ export const getTimestampByIndex = where ic == ${index + 1}; `); - if (!result.ok) return result; - return ok(result.value.rows[0].pt); + return result.rows[0].pt; }; /** Retrieves usage information for an owner from the evolu_usage table. */ @@ -1645,14 +1553,11 @@ export const getOwnerUsage = ( ownerIdBytes: OwnerIdBytes, initialTimestamp: TimestampBytes, - ): Result< - { - storedBytes: NonNegativeInt | null; - firstTimestamp: TimestampBytes; - lastTimestamp: TimestampBytes; - }, - SqliteError - > => { + ): Result<{ + storedBytes: NonNegativeInt | null; + firstTimestamp: TimestampBytes; + lastTimestamp: TimestampBytes; + }> => { const result = deps.sqlite.exec<{ storedBytes: NonNegativeInt; firstTimestamp: TimestampBytes | null; @@ -1662,9 +1567,8 @@ export const getOwnerUsage = from evolu_usage where ownerId = ${ownerIdBytes}; `); - if (!result.ok) return result; - if (!isNonEmptyArray(result.value.rows)) { + if (!isNonEmptyArray(result.rows)) { return ok({ storedBytes: null, firstTimestamp: initialTimestamp, @@ -1672,7 +1576,7 @@ export const getOwnerUsage = }); } - const row = firstInArray(result.value.rows); + const row = firstInArray(result.rows); assert(row.firstTimestamp, "not null"); assert(row.lastTimestamp, "not null"); @@ -1696,8 +1600,8 @@ export const updateOwnerUsage = storedBytes: PositiveInt, firstTimestamp: TimestampBytes, lastTimestamp: TimestampBytes, - ): Result => { - const result = deps.sqlite.exec(sql` + ): void => { + deps.sqlite.exec(sql` insert into evolu_usage ("ownerId", "storedBytes", "firstTimestamp", "lastTimestamp") values @@ -1708,6 +1612,4 @@ export const updateOwnerUsage = firstTimestamp = ${firstTimestamp}, lastTimestamp = ${lastTimestamp}; `); - if (!result.ok) return result; - return ok(); }; diff --git a/packages/common/src/local-first/Sync.ts b/packages/common/src/local-first/Sync.ts index fb67c09ef..15fde944c 100644 --- a/packages/common/src/local-first/Sync.ts +++ b/packages/common/src/local-first/Sync.ts @@ -6,7 +6,7 @@ import type { NonEmptyArray, NonEmptyReadonlyArray } from "../Array.js"; import { appendToArray, firstInArray, isNonEmptyArray } from "../Array.js"; -import { assertNonEmptyReadonlyArray } from "../Assert.js"; +import { assert, assertNonEmptyReadonlyArray } from "../Assert.js"; import type { Brand } from "../Brand.js"; import type { ConsoleDep } from "../Console.js"; import type { @@ -14,13 +14,13 @@ import type { RandomBytesDep, } from "../Crypto.js"; import type { UnknownError } from "../Error.js"; -import { lazyFalse, lazyTrue, todo } from "../Function.js"; +import { lazyFalse, lazyVoid, todo } from "../Function.js"; import { createRecord, getProperty, objectToEntries } from "../Object.js"; import type { RandomDep } from "../Random.js"; import { createResources } from "../Resources.js"; import type { Result } from "../Result.js"; -import { err, ok } from "../Result.js"; -import type { SqliteDep, SqliteError } from "../Sqlite.js"; +import { ok } from "../Result.js"; +import type { SqliteDep } from "../Sqlite.js"; import { booleanToSqliteBoolean, SqliteBoolean, @@ -68,12 +68,7 @@ import { } from "./Protocol.js"; import type { DbSchemaDep, MutationChange } from "./Schema.js"; import { systemColumns } from "./Schema.js"; -import type { - BaseSqliteStorage, - CrdtMessage, - Storage, - StorageWriteError, -} from "./Storage.js"; +import type { BaseSqliteStorage, CrdtMessage, Storage } from "./Storage.js"; import { createBaseSqliteStorage, DbChange, @@ -111,7 +106,6 @@ export interface Sync extends Disposable { changes: NonEmptyReadonlyArray, ) => Result< void, - | SqliteError | TimestampCounterOverflowError | TimestampDriftError | TimestampTimeOutOfRangeError @@ -149,7 +143,6 @@ export interface SyncConfig { | ProtocolError | ProtocolInvalidDataError | ProtocolTimestampMismatchError - | SqliteError | DecryptWithXChaCha20Poly1305Error | TimestampCounterOverflowError | TimestampDriftError @@ -173,7 +166,7 @@ export const createSync = TimeDep & TimestampConfigDep, ) => - (config: SyncConfig): Result => { + (config: SyncConfig): Sync => { let isDisposed = false; /** Returns owner data only if actively assigned to at least one transport. */ @@ -182,14 +175,11 @@ export const createSync = return resources.getConsumer(ownerId); }; - const storageResult = createClientStorage({ + const storage = createClientStorage({ ...deps, getSyncOwner, })(config); - if (!storageResult.ok) return storageResult; - const storage = storageResult.value; - const createResource = (transport: OwnerTransport): WebSocket => { const transportKey = createTransportKey(transport); @@ -298,10 +288,10 @@ export const createSync = // The onOpen handler will sync it. if (!webSocket.isOpen()) return; - const message = createProtocolMessageForSync({ storage })( - owner.id, - SubscriptionFlags.Subscribe, - ); + const message = createProtocolMessageForSync({ + storage, + console: deps.console, + })(owner.id, SubscriptionFlags.Subscribe); if (message) webSocket.send(message); }, @@ -367,8 +357,7 @@ export const createSync = } for (const [ownerId, messages] of ownerMessages) { - const result = applyMessages({ ...deps, storage })(ownerId, messages); - if (!result.ok) return result; + applyMessages({ ...deps, storage })(ownerId, messages); const owner = getSyncOwner(ownerId); if (!owner?.writeKey) continue; @@ -398,7 +387,8 @@ export const createSync = } } - return deps.clock.save(clockTimestamp); + deps.clock.save(clockTimestamp); + return ok(); }, [Symbol.dispose]: () => { @@ -408,7 +398,7 @@ export const createSync = }, }; - return ok(sync); + return sync; }; export interface ClockDep { @@ -417,7 +407,7 @@ export interface ClockDep { export interface Clock { readonly get: () => Timestamp; - readonly save: (timestamp: Timestamp) => Result; + readonly save: (timestamp: Timestamp) => void; } export const createClock = @@ -430,13 +420,10 @@ export const createClock = save: (timestamp) => { currentTimestamp = timestamp; - const result = deps.sqlite.exec(sql.prepared` + deps.sqlite.exec(sql.prepared` update evolu_config set "clock" = ${timestampToTimestampBytes(timestamp)}; `); - if (!result.ok) return result; - - return ok(); }, }; }; @@ -467,18 +454,14 @@ const createClientStorage = error: | ProtocolInvalidDataError | ProtocolTimestampMismatchError - | SqliteError | DecryptWithXChaCha20Poly1305Error | TimestampCounterOverflowError | TimestampDriftError | TimestampTimeOutOfRangeError, ) => void; onReceive: () => void; - }): Result => { - const sqliteStorageBase = createBaseSqliteStorage(deps)({ - onStorageError: config.onError, - isOwnerWithinQuota: lazyTrue, // Clients don't have quota limits - }); + }): ClientStorage => { + const sqliteStorageBase = createBaseSqliteStorage(deps)(); // TODO: Mutex per OwnerId like in Relay to support more owners. const mutex = createMutex(); @@ -488,7 +471,7 @@ const createClientStorage = // Not implemented yet. validateWriteKey: lazyFalse, - setWriteKey: lazyFalse, + setWriteKey: lazyVoid, writeMessages: (ownerIdBytes, encryptedMessages) => async (run) => { const ownerId = ownerIdBytesToOwnerId(ownerIdBytes); @@ -499,7 +482,6 @@ const createClientStorage = boolean, | ProtocolInvalidDataError | ProtocolTimestampMismatchError - | SqliteError | DecryptWithXChaCha20Poly1305Error | TimestampCounterOverflowError | TimestampDriftError @@ -545,14 +527,11 @@ const createClientStorage = } if (isNonEmptyArray(messages)) { - const result = applyMessages({ ...deps, storage })( - owner.id, - messages, - ); - if (!result.ok) return result; + applyMessages({ ...deps, storage })(owner.id, messages); } - return deps.clock.save(clockTimestamp); + deps.clock.save(clockTimestamp); + return ok(); }); if (!transaction.ok) return transaction; @@ -565,8 +544,9 @@ const createClientStorage = if (!result.ok) { if (result.error.type !== "AbortError") { config.onError(result.error); + throw new Error(result.error.type, { cause: result.error }); } - return err({ type: "StorageWriteError", ownerId }); + return ok(); } config.onReceive(); @@ -576,8 +556,7 @@ const createClientStorage = readDbChange: (ownerId, timestamp) => { const owner = deps.getSyncOwner(ownerIdBytesToOwnerId(ownerId)); - // Owner can be removed to stop syncing. - if (!owner) return null; + assert(owner, "Sync owner must exist while reading db change"); const result = deps.sqlite.exec<{ readonly table: string; @@ -594,12 +573,7 @@ const createClientStorage = where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}; `); - if (!result.ok) { - config.onError(result.error); - return null; - } - - const { rows } = result.value; + const { rows } = result; assertNonEmptyReadonlyArray(rows, "Every timestamp must have rows"); const firstRow = firstInArray(rows); @@ -640,7 +614,7 @@ const createClientStorage = }, }; - return ok(storage); + return storage; }; type TransportKey = string & Brand<"TransportKey">; @@ -669,45 +643,38 @@ const dbChangeToColumns = (change: DbChange, now: Millis) => { export const applyLocalOnlyChange = (deps: SqliteDep & TimeDep & AppOwnerDep) => - (change: MutationChange): Result => { + (change: MutationChange): void => { if (change.isDelete) { - const result = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from ${sql.identifier(change.table)} where id = ${change.id}; `); - if (!result.ok) return result; } else { const ownerId = deps.appOwner.id; const columns = dbChangeToColumns(change, deps.time.now()); for (const [column, value] of columns) { - const result = deps.sqlite.exec(sql.prepared` + deps.sqlite.exec(sql.prepared` insert into ${sql.identifier(change.table)} ("ownerId", "id", ${sql.identifier(column)}) values (${ownerId}, ${change.id}, ${value}) on conflict ("ownerId", "id") do update set ${sql.identifier(column)} = ${value}; `); - if (!result.ok) return result; } } - - return ok(); }; const applyMessages = (deps: ClientStorageDep & ClockDep & DbSchemaDep & RandomDep & SqliteDep) => - ( - ownerId: OwnerId, - messages: NonEmptyReadonlyArray, - ): Result => { + (ownerId: OwnerId, messages: NonEmptyReadonlyArray): void => { const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId); const usage = getOwnerUsage(deps)( ownerIdBytes, timestampToTimestampBytes(firstInArray(messages).timestamp), ); - if (!usage.ok) return usage; + if (!usage.ok) return; let { firstTimestamp, lastTimestamp } = usage.value; @@ -718,7 +685,7 @@ const applyMessages = for (const [column, value] of columns) { if (validateColumnValue(deps)(change.table, column, value)) { - const result = applyColumnChange(deps)( + applyColumnChange(deps)( ownerIdBytes, ownerId, change.table, @@ -728,9 +695,8 @@ const applyMessages = value, timestampBytes, ); - if (!result.ok) return result; } else { - const result = deps.sqlite.exec(sql.prepared` + deps.sqlite.exec(sql.prepared` insert into evolu_message_quarantine ("ownerId", "timestamp", "table", "id", "column", "value") values @@ -744,7 +710,6 @@ const applyMessages = ) on conflict do nothing; `); - if (!result.ok) return result; } } @@ -755,19 +720,15 @@ const applyMessages = lastTimestamp, ); - const result = deps.storage.insertTimestamp( - ownerIdBytes, - timestampBytes, - strategy, - ); - if (!result.ok) return result; + // TODO: Rethink and maybe refactor. + deps.storage.insertTimestamp(ownerIdBytes, timestampBytes, strategy); } /** * TODO: Implement proper storedBytes tracking for client using received and * sent encrypted message sizes. */ - return updateOwnerUsage(deps)( + updateOwnerUsage(deps)( ownerIdBytes, 1 as PositiveInt, // Placeholder until proper tracking implemented firstTimestamp, @@ -804,8 +765,8 @@ const applyColumnChange = column: string, value: SqliteValue, timestampBytes: TimestampBytes, - ): Result => { - const result = deps.sqlite.exec(sql.prepared` + ): void => { + deps.sqlite.exec(sql.prepared` with existingTimestamp as ( select 1 @@ -826,10 +787,9 @@ const applyColumnChange = set ${sql.identifier(column)} = ${value} where not exists (select 1 from existingTimestamp); `); - if (!result.ok) return result; { - const result = deps.sqlite.exec(sql.prepared` + deps.sqlite.exec(sql.prepared` insert into evolu_history ("ownerId", "table", "id", "column", "value", "timestamp") values @@ -843,10 +803,7 @@ const applyColumnChange = ) on conflict do nothing; `); - if (!result.ok) return result; } - - return ok(); }; /** @@ -855,7 +812,7 @@ const applyColumnChange = * don't exist in the current schema (e.g., from a newer app version). */ export const tryApplyQuarantinedMessages = - (deps: DbSchemaDep & SqliteDep) => (): Result => { + (deps: DbSchemaDep & SqliteDep) => (): void => { const rows = deps.sqlite.exec<{ readonly ownerId: OwnerIdBytes; readonly timestamp: TimestampBytes; @@ -867,13 +824,12 @@ export const tryApplyQuarantinedMessages = select "ownerId", "timestamp", "table", "id", "column", "value" from evolu_message_quarantine; `); - if (!rows.ok) return rows; - for (const row of rows.value.rows) { + for (const row of rows.rows) { if (!validateColumnValue(deps)(row.table, row.column, row.value)) continue; - const result = applyColumnChange(deps)( + applyColumnChange(deps)( row.ownerId, ownerIdBytesToOwnerId(row.ownerId), row.table, @@ -883,10 +839,9 @@ export const tryApplyQuarantinedMessages = row.value, row.timestamp, ); - if (!result.ok) return result; { - const result = deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from evolu_message_quarantine where "ownerId" = ${row.ownerId} @@ -895,11 +850,8 @@ export const tryApplyQuarantinedMessages = and "id" = ${row.id} and "column" = ${row.column}; `); - if (!result.ok) return result; } } - - return ok(); }; /** diff --git a/packages/common/test/Sqlite.test.ts b/packages/common/test/Sqlite.test.ts index 070357d2f..e5bf6dc0f 100644 --- a/packages/common/test/Sqlite.test.ts +++ b/packages/common/test/Sqlite.test.ts @@ -56,13 +56,10 @@ test("basic DDL/DML works", async () => { await using run = await testCreateRunWithSqlite(); const { sqlite } = run.deps; - expect(sqlite.exec(sql`create table a (data);`).ok).toBe(true); - expect(sqlite.exec(sql`insert into a (data) values (${"foo"});`).ok).toBe( - true, - ); + sqlite.exec(sql`create table a (data);`); + sqlite.exec(sql`insert into a (data) values (${"foo"});`); const result = sqlite.exec(sql`select * from a;`); - assert(result.ok); - expect(result.value.rows).toEqual([{ data: "foo" }]); + expect(result.rows).toEqual([{ data: "foo" }]); }); describe("transactions", () => { @@ -72,22 +69,15 @@ describe("transactions", () => { sqlite.exec(sql`create table a (data);`); - const result = sqlite.transaction(() => - sqlite.exec(sql`insert into notexisting (data) values (${"foo"});`), - ); + const action = () => + sqlite.transaction(() => + ok(sqlite.exec(sql`insert into notexisting (data) values (${"foo"});`)), + ); - expect(result).toEqual( - err( - expect.objectContaining({ - type: "SqliteError", - error: expect.anything(), - }), - ), - ); + expect(action).toThrow(); const rows = sqlite.exec(sql`select * from a;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([]); + expect(rows.rows).toEqual([]); const entries = console.getEntriesSnapshot(); const debugLogs = entries.filter((e) => e.method === "debug"); @@ -111,8 +101,7 @@ describe("transactions", () => { } const rows = sqlite.exec(sql`select * from a;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([]); + expect(rows.rows).toEqual([]); const entries = console.getEntriesSnapshot(); const debugLogs = entries.filter((e) => e.method === "debug"); @@ -125,15 +114,15 @@ describe("transactions", () => { sqlite.exec(sql`create table a (data);`); - const result = sqlite.transaction(() => - sqlite.exec(sql`insert into a (data) values (${"bar"});`), - ); + const result = sqlite.transaction(() => { + sqlite.exec(sql`insert into a (data) values (${"bar"});`); + return ok(); + }); expect(result.ok).toBe(true); const rows = sqlite.exec(sql`select * from a;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ data: "bar" }]); + expect(rows.rows).toEqual([{ data: "bar" }]); }); test("transaction callback returns error", async () => { @@ -179,9 +168,8 @@ describe("transactions", () => { assert(sqliteResult.ok); const sqlite = sqliteResult.value; - const result = sqlite.transaction(() => ok("should not reach")); + expect(() => sqlite.transaction(() => ok("should not reach"))).toThrow(); - expect(result.ok).toBe(false); expect(beginCalled).toBe(true); expect(rollbackCalled).toBe(false); }); @@ -192,26 +180,19 @@ describe("transactions", () => { sqlite.exec(sql`create table a (data);`); - const result = sqlite.transaction(() => { - sqlite.exec(sql`insert into a (data) values (${"boom"});`); - throw new Error("Callback failed"); - }); + const action = () => + sqlite.transaction(() => { + sqlite.exec(sql`insert into a (data) values (${"boom"});`); + throw new Error("Callback failed"); + }); - expect(result).toEqual( - err( - expect.objectContaining({ - type: "SqliteError", - error: expect.anything(), - }), - ), - ); + expect(action).toThrow(); const rows = sqlite.exec(sql`select * from a;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([]); + expect(rows.rows).toEqual([]); }); - test("rollback failure logs warning and returns both errors", async () => { + test("rollback failure throws rollback error", async () => { let rollbackCalled = false; const createFailingDriver: CreateSqliteDriver = () => () => { @@ -240,28 +221,11 @@ describe("transactions", () => { const sqliteResult = await run(createSqlite(testSimpleName)); assert(sqliteResult.ok); const sqlite = sqliteResult.value; - const { console } = run.deps; - const result = sqlite.transaction(() => - sqlite.exec(sql`select * from users;`), - ); - - expect(result.ok).toBe(false); + expect(() => + sqlite.transaction(() => ok(sqlite.exec(sql`select * from users;`))), + ).toThrow(); expect(rollbackCalled).toBe(true); - - if (!result.ok) { - expect(result.error.type).toBe("SqliteError"); - expect(result.error.rollbackError).toEqual( - expect.objectContaining({ - type: "UnknownError", - error: expect.objectContaining({ message: "Rollback failed" }), - }), - ); - } - - const entries = console.getEntriesSnapshot(); - const warnLogs = entries.filter((e) => e.method === "warn"); - expect(warnLogs.map((e) => e.args[0])).toContain("rollback failed"); }); test("transaction commit failure triggers rollback", async () => { @@ -297,9 +261,7 @@ describe("transactions", () => { assert(sqliteResult.ok); const sqlite = sqliteResult.value; - const result = sqlite.transaction(() => ok("data")); - - expect(result.ok).toBe(false); + expect(() => sqlite.transaction(() => ok("data"))).toThrow(); expect(commitCalled).toBe(true); expect(rollbackCalled).toBe(true); }); @@ -314,12 +276,11 @@ describe("export", () => { sqlite.exec(sql`insert into a (data) values (${"foo"});`); const result = sqlite.export(); - assert(result.ok); - expect(result.value).toBeInstanceOf(Uint8Array); - expect(result.value.length).toBeGreaterThan(0); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBeGreaterThan(0); }); - test("export failure returns SqliteError", async () => { + test("export failure throws", async () => { const createFailingDriver: CreateSqliteDriver = () => () => { const driver: SqliteDriver = { exec: () => ({ rows: [], changes: 0 }), @@ -338,11 +299,7 @@ describe("export", () => { assert(sqliteResult.ok); const sqlite = sqliteResult.value; - const result = sqlite.export(); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.type).toBe("SqliteError"); - } + expect(() => sqlite.export()).toThrow("Export failed"); }); }); @@ -376,7 +333,7 @@ describe("logExplainQueryPlan", () => { }; const result = sqlite.exec(query); - expect(result.ok).toBe(true); + expect(result.rows).toEqual([]); const entries = run.deps.console.getEntriesSnapshot(); const logEntries = entries.filter( diff --git a/packages/common/test/local-first/Protocol.test.ts b/packages/common/test/local-first/Protocol.test.ts index 7ec5c6bc8..833095bd5 100644 --- a/packages/common/test/local-first/Protocol.test.ts +++ b/packages/common/test/local-first/Protocol.test.ts @@ -1062,7 +1062,7 @@ describe("E2E sync", () => { relayStorage: Storage, rangesMaxSize = defaultProtocolMessageRangesMaxSize, ) => { - const clientStorageDep = { storage: clientStorage }; + const clientStorageDep = { storage: clientStorage, console: deps.console }; const relayStorageDep = { storage: relayStorage }; let message = createProtocolMessageForSync(clientStorageDep)( @@ -1112,7 +1112,7 @@ describe("E2E sync", () => { testAppOwnerIdBytes, timestampToTimestampBytes(message.timestamp), ) - ?.join(), + .join(), ).toBe(message.change.join()); expect( @@ -1121,7 +1121,7 @@ describe("E2E sync", () => { testAppOwnerIdBytes, timestampToTimestampBytes(message.timestamp), ) - ?.join(), + .join(), ).toBe(message.change.join()); } diff --git a/packages/common/test/local-first/Relay.test.ts b/packages/common/test/local-first/Relay.test.ts index c7bc5d7de..cbc9015d3 100644 --- a/packages/common/test/local-first/Relay.test.ts +++ b/packages/common/test/local-first/Relay.test.ts @@ -64,8 +64,7 @@ test("deleteOwner", async () => { expect(storage.getSize(testAppOwnerIdBytes)).toBe(1); - const deleteResult = storage.deleteOwner(testAppOwnerIdBytes); - expect(deleteResult).toBe(true); + storage.deleteOwner(testAppOwnerIdBytes); for (const table of ["evolu_timestamp", "evolu_message", "evolu_writeKey"]) { const countResult = sqlite.exec<{ count: number }>(sql` @@ -73,7 +72,7 @@ test("deleteOwner", async () => { from ${sql.raw(table)} where ownerid = ${testAppOwnerIdBytes}; `); - expect(countResult.ok && countResult.value.rows[0].count).toBe(0); + expect(countResult.rows[0].count).toBe(0); } }); @@ -92,8 +91,7 @@ describe("writeMessages", () => { from evolu_usage where ownerid = ${ownerId}; `); - assert(usageResult.ok); - return usageResult.value.rows[0].storedBytes as NonNegativeInt; + return usageResult.rows[0].storedBytes as NonNegativeInt; }; const message = createTestMessage(); @@ -137,8 +135,7 @@ describe("writeMessages", () => { where ownerid = ${testAppOwnerIdBytes}; `); - assert(countResult.ok); - expect(countResult.value.rows[0].count).toBe(1); + expect(countResult.rows[0].count).toBe(1); }); test("mutex prevents concurrent writes for same owner", async () => { @@ -218,8 +215,7 @@ describe("writeMessages", () => { where ownerid = ${testAppOwnerIdBytes}; `); - assert(messageCountResult.ok); - expect(messageCountResult.value.rows[0].count).toBe(0); + expect(messageCountResult.rows[0].count).toBe(0); const usageResult = sqlite.exec<{ count: number }>(sql` select count(*) as count @@ -227,8 +223,7 @@ describe("writeMessages", () => { where ownerid = ${testAppOwnerIdBytes}; `); - assert(usageResult.ok); - expect(usageResult.value.rows[0].count).toBe(0); + expect(usageResult.rows[0].count).toBe(0); }); describe("isOwnerWithinQuota", () => { diff --git a/packages/common/test/local-first/Schema.test.ts b/packages/common/test/local-first/Schema.test.ts index 88025e334..879227679 100644 --- a/packages/common/test/local-first/Schema.test.ts +++ b/packages/common/test/local-first/Schema.test.ts @@ -14,7 +14,6 @@ import { ensureDbSchema, getDbSchema, } from "../../src/local-first/Schema.js"; -import { ok } from "../../src/Result.js"; import { SqliteBoolean } from "../../src/Sqlite.js"; import { Boolean, @@ -263,21 +262,17 @@ describe("ensureDbSchema", () => { indexes: [], }; - const result = ensureDbSchema(run.deps)(newSchema); - expect(result).toEqual(ok()); + ensureDbSchema(run.deps)(newSchema); const dbSchema = getDbSchema(run.deps)(); - expect(dbSchema.ok).toBe(true); - if (!dbSchema.ok) return; - - expect(dbSchema.value.tables.todo).toBeDefined(); - expect(dbSchema.value.tables.todo.has("id")).toBe(true); - expect(dbSchema.value.tables.todo.has("title")).toBe(true); - expect(dbSchema.value.tables.todo.has("isCompleted")).toBe(true); - expect(dbSchema.value.tables.todo.has("createdAt")).toBe(true); - expect(dbSchema.value.tables.todo.has("updatedAt")).toBe(true); - expect(dbSchema.value.tables.todo.has("isDeleted")).toBe(true); - expect(dbSchema.value.tables.todo.has("ownerId")).toBe(true); + expect(dbSchema.tables.todo).toBeDefined(); + expect(dbSchema.tables.todo.has("id")).toBe(true); + expect(dbSchema.tables.todo.has("title")).toBe(true); + expect(dbSchema.tables.todo.has("isCompleted")).toBe(true); + expect(dbSchema.tables.todo.has("createdAt")).toBe(true); + expect(dbSchema.tables.todo.has("updatedAt")).toBe(true); + expect(dbSchema.tables.todo.has("isDeleted")).toBe(true); + expect(dbSchema.tables.todo.has("ownerId")).toBe(true); }); test("adds new columns to existing tables", async () => { @@ -290,8 +285,7 @@ describe("ensureDbSchema", () => { indexes: [], }; - const result1 = ensureDbSchema(run.deps)(initialSchema); - expect(result1).toEqual(ok()); + ensureDbSchema(run.deps)(initialSchema); const updatedSchema: DbSchema = { tables: { @@ -300,16 +294,12 @@ describe("ensureDbSchema", () => { indexes: [], }; - const result2 = ensureDbSchema(run.deps)(updatedSchema); - expect(result2).toEqual(ok()); + ensureDbSchema(run.deps)(updatedSchema); const dbSchema = getDbSchema(run.deps)(); - expect(dbSchema.ok).toBe(true); - if (!dbSchema.ok) return; - - expect(dbSchema.value.tables.todo.has("title")).toBe(true); - expect(dbSchema.value.tables.todo.has("isCompleted")).toBe(true); - expect(dbSchema.value.tables.todo.has("priority")).toBe(true); + expect(dbSchema.tables.todo.has("title")).toBe(true); + expect(dbSchema.tables.todo.has("isCompleted")).toBe(true); + expect(dbSchema.tables.todo.has("priority")).toBe(true); }); test("creates multiple tables", async () => { @@ -323,17 +313,13 @@ describe("ensureDbSchema", () => { indexes: [], }; - const result = ensureDbSchema(run.deps)(newSchema); - expect(result).toEqual(ok()); + ensureDbSchema(run.deps)(newSchema); const dbSchema = getDbSchema(run.deps)(); - expect(dbSchema.ok).toBe(true); - if (!dbSchema.ok) return; - - expect(dbSchema.value.tables.todo).toBeDefined(); - expect(dbSchema.value.tables.category).toBeDefined(); - expect(dbSchema.value.tables.todo.has("title")).toBe(true); - expect(dbSchema.value.tables.category.has("name")).toBe(true); + expect(dbSchema.tables.todo).toBeDefined(); + expect(dbSchema.tables.category).toBeDefined(); + expect(dbSchema.tables.todo.has("title")).toBe(true); + expect(dbSchema.tables.category.has("name")).toBe(true); }); test("uses set difference to find new columns", async () => { @@ -355,20 +341,16 @@ describe("ensureDbSchema", () => { indexes: [], }; - const result = ensureDbSchema(run.deps)(updatedSchema); - expect(result).toEqual(ok()); + ensureDbSchema(run.deps)(updatedSchema); const dbSchema = getDbSchema(run.deps)(); - expect(dbSchema.ok).toBe(true); - if (!dbSchema.ok) return; - // Original columns still exist - expect(dbSchema.value.tables.todo.has("a")).toBe(true); - expect(dbSchema.value.tables.todo.has("b")).toBe(true); - expect(dbSchema.value.tables.todo.has("c")).toBe(true); + expect(dbSchema.tables.todo.has("a")).toBe(true); + expect(dbSchema.tables.todo.has("b")).toBe(true); + expect(dbSchema.tables.todo.has("c")).toBe(true); // New columns added via difference - expect(dbSchema.value.tables.todo.has("d")).toBe(true); - expect(dbSchema.value.tables.todo.has("e")).toBe(true); + expect(dbSchema.tables.todo.has("d")).toBe(true); + expect(dbSchema.tables.todo.has("e")).toBe(true); }); test("with currentSchema parameter skips getDbSchema call", async () => { @@ -392,13 +374,9 @@ describe("ensureDbSchema", () => { }; // Pass currentSchema to skip getDbSchema - const result = ensureDbSchema(run.deps)(newSchema, currentSchema); - expect(result).toEqual(ok()); + ensureDbSchema(run.deps)(newSchema, currentSchema); const dbSchema = getDbSchema(run.deps)(); - expect(dbSchema.ok).toBe(true); - if (!dbSchema.ok) return; - - expect(dbSchema.value.tables.todo.has("description")).toBe(true); + expect(dbSchema.tables.todo.has("description")).toBe(true); }); }); diff --git a/packages/common/test/local-first/Storage.test.ts b/packages/common/test/local-first/Storage.test.ts index 590e93d22..6167d77a5 100644 --- a/packages/common/test/local-first/Storage.test.ts +++ b/packages/common/test/local-first/Storage.test.ts @@ -1,6 +1,5 @@ import { sha256 } from "@noble/hashes/sha2.js"; import { assert, expect, test } from "vitest"; -import { lazyTrue } from "../../src/Function.js"; import { ownerIdToOwnerIdBytes } from "../../src/local-first/Owner.js"; import type { BaseSqliteStorageDep, @@ -25,7 +24,7 @@ import { } from "../../src/local-first/Timestamp.js"; import { computeBalancedBuckets } from "../../src/Number.js"; import { createRandom } from "../../src/Random.js"; -import { getOrThrow, ok } from "../../src/Result.js"; +import { ok } from "../../src/Result.js"; import type { SqliteDep } from "../../src/Sqlite.js"; import { sql } from "../../src/Sqlite.js"; import { testCreateDeps } from "../../src/Test.js"; @@ -49,15 +48,9 @@ const createDeps = async (): Promise => { // Pseudo-random does not scale (randomness is limited). const random = createRandom(); - const result = createBaseSqliteStorageTables({ sqlite }); - assert(result.ok); + createBaseSqliteStorageTables({ sqlite }); - const storage = createBaseSqliteStorage({ sqlite, random })({ - onStorageError: (error) => { - throw new Error(error.type); - }, - isOwnerWithinQuota: lazyTrue, // Allow all writes in tests - }); + const storage = createBaseSqliteStorage({ sqlite, random })(); return { sqlite, storage }; }; @@ -136,16 +129,14 @@ const testTimestamps = async ( ? null : (fingerprintRanges[i].upperBound as TimestampBytes); - const { rows: timestampRows } = getOrThrow( - deps.sqlite.exec<{ t: TimestampBytes }>(sql` - select t - from evolu_timestamp - where - (${lower} is null or t >= ${lower}) - and (${upper} is null or t < ${upper}) - and ownerid = ${testAppOwnerIdBytes}; - `), - ); + const { rows: timestampRows } = deps.sqlite.exec<{ t: TimestampBytes }>(sql` + select t + from evolu_timestamp + where + (${lower} is null or t >= ${lower}) + and (${upper} is null or t < ${upper}) + and ownerid = ${testAppOwnerIdBytes}; + `); incrementalCounts.push(timestampRows.length); @@ -217,7 +208,7 @@ test("empty db", async () => { 0 as NonNegativeInt, 0 as NonNegativeInt, ); - expect(fingerprint?.join()).toBe("0,0,0,0,0,0,0,0,0,0,0,0"); + expect(fingerprint.join()).toBe("0,0,0,0,0,0,0,0,0,0,0,0"); const lowerBound = deps.storage.findLowerBound( testAppOwnerIdBytes, @@ -383,8 +374,7 @@ test("getTimestampByIndex", async () => { testAppOwnerIdBytes, i as NonNegativeInt, ); - assert(timestamp.ok); - expect(timestamp.value.join()).toBe(testTimestampsAsc[i].join()); + expect(timestamp.join()).toBe(testTimestampsAsc[i].join()); } }); diff --git a/packages/common/test/local-first/_utils.ts b/packages/common/test/local-first/_utils.ts index 5664fc2f9..18eeaab31 100644 --- a/packages/common/test/local-first/_utils.ts +++ b/packages/common/test/local-first/_utils.ts @@ -1,12 +1,11 @@ -import { assert } from "../../src/Assert.js"; -import { type DbSchema, getDbSchema } from "../../src/local-first/Schema.js"; +import { DbSchema, getDbSchema } from "../../src/local-first/Schema.js"; import type { CrdtMessage } from "../../src/local-first/Storage.js"; import { DbChange } from "../../src/local-first/Storage.js"; import { createTimestamp } from "../../src/local-first/Timestamp.js"; import type { SqliteDep } from "../../src/Sqlite.js"; import { sql } from "../../src/Sqlite.js"; import { Millis } from "../../src/Time.js"; -import type { Id } from "../../src/Type.js"; +import { Id } from "../../src/Type.js"; export interface DbSnapshot { readonly schema: DbSchema; @@ -18,23 +17,21 @@ export interface DbSnapshot { export const getDbSnapshot = (deps: SqliteDep): DbSnapshot => { const schema = getDbSchema(deps)({ allIndexes: true }); - assert(schema.ok, "bug"); const tables = []; - for (const tableName in schema.value.tables) { + for (const tableName in schema.tables) { const result = deps.sqlite.exec(sql` select * from ${sql.identifier(tableName)}; `); - assert(result.ok, "bug"); tables.push({ name: tableName, - rows: result.value.rows, + rows: result.rows, }); } - return { schema: schema.value, tables }; + return { schema, tables }; }; export const createTestCrdtMessage = ( diff --git a/packages/nodejs/src/local-first/Relay.ts b/packages/nodejs/src/local-first/Relay.ts index 0806efc58..ba6a9c370 100644 --- a/packages/nodejs/src/local-first/Relay.ts +++ b/packages/nodejs/src/local-first/Relay.ts @@ -1,25 +1,21 @@ -import { existsSync } from "node:fs"; -import { createServer } from "node:http"; import { - allResult, - type CreateSqliteDriverDep, callback, createRandom, createRelation, createSqlite, + type CreateSqliteDriverDep, isPromiseLike, - type OwnerId, ok, + OwnerId, type RandomDep, - SimpleName, - type SqliteError, + Name, type Task, type TimingSafeEqualDep, Uint8Array, } from "@evolu/common"; import { - type ApplyProtocolMessageAsRelayOptions, applyProtocolMessageAsRelay, + type ApplyProtocolMessageAsRelayOptions, createBaseSqliteStorageTables, createRelaySqliteStorage, createRelayStorageTables, @@ -28,9 +24,11 @@ import { type Relay, type RelayConfig, } from "@evolu/common/local-first"; +import { existsSync } from "fs"; +import { createServer } from "http"; import { WebSocket, WebSocketServer } from "ws"; -import { createTimingSafeEqual } from "../Crypto.js"; import { createBetterSqliteDriver } from "../Sqlite.js"; +import { createTimingSafeEqual } from "../Crypto.js"; export interface NodeJsRelayConfig extends RelayConfig { /** The port number for the HTTP server. */ @@ -57,7 +55,7 @@ export const createRelayDeps = (): RelayDeps => ({ * ```ts * const deps = { ...createRelayDeps(), console }; * - * await using run = createRunner(deps); + * await using run = createRun(deps); * await using stack = run.stack(); * * await stack.use(startRelay({ port: 4000 })); @@ -68,36 +66,26 @@ export const createRelayDeps = (): RelayDeps => ({ export const startRelay = ({ port = 443, - name = SimpleName.orThrow("evolu-relay"), + name = Name.orThrow("evolu-relay"), isOwnerAllowed, isOwnerWithinQuota, - }: NodeJsRelayConfig): Task => + }: NodeJsRelayConfig): Task => async (_run) => { await using stack = _run.stack(); const console = _run.deps.console.child("relay"); const dbFileExists = existsSync(`${name}.db`); - const sqlite = await stack.use(createSqlite(name)); - if (!sqlite.ok) { - console.error(sqlite.error); - return sqlite; - } - const deps = { ..._run.deps, sqlite: sqlite.value }; + const sqliteResult = await stack.use(createSqlite(name)); + if (!sqliteResult.ok) return sqliteResult; + const deps = { ..._run.deps, sqlite: sqliteResult.value }; if (!dbFileExists) { - const result = allResult([ - createBaseSqliteStorageTables(deps), - createRelayStorageTables(deps), - ]); - if (!result.ok) { - console.error(result.error); - return result; - } + createBaseSqliteStorageTables(deps); + createRelayStorageTables(deps); } const storage = createRelaySqliteStorage(deps)({ - onStorageError: console.error, isOwnerWithinQuota, }); @@ -221,7 +209,6 @@ export const startRelay = console.info("HTTP server closed"); ok(); }); - return undefined; }), ); @@ -233,7 +220,6 @@ export const startRelay = console.info("WebSocketServer closed"); ok(); }); - return undefined; }), ); @@ -246,7 +232,6 @@ export const startRelay = } } ok(); - return undefined; }), ); diff --git a/packages/nodejs/test/Sqlite.test.ts b/packages/nodejs/test/Sqlite.test.ts index aa52fd56a..e7d992f4a 100644 --- a/packages/nodejs/test/Sqlite.test.ts +++ b/packages/nodejs/test/Sqlite.test.ts @@ -23,8 +23,7 @@ describe("createBetterSqliteDriver", () => { sqlite.exec(sql`create table t (data text);`); sqlite.exec(sql`insert into t (data) values (${"hello"});`); const rows = sqlite.exec(sql`select * from t;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ data: "hello" }]); + expect(rows.rows).toEqual([{ data: "hello" }]); sqlite[Symbol.dispose](); }); @@ -42,9 +41,8 @@ describe("createBetterSqliteDriver", () => { sqlite.exec(sql`insert into t (name) values (${"Bob"});`); const rows = sqlite.exec(sql`select name from t order by id;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ name: "Alice" }, { name: "Bob" }]); - expect(rows.value.changes).toBe(0); + expect(rows.rows).toEqual([{ name: "Alice" }, { name: "Bob" }]); + expect(rows.changes).toBe(0); sqlite[Symbol.dispose](); }); @@ -62,9 +60,8 @@ describe("createBetterSqliteDriver", () => { sqlite.exec(sql`insert into t (name) values (${"Bob"});`); const deleteResult = sqlite.exec(sql`delete from t;`); - assert(deleteResult.ok); - expect(deleteResult.value.rows).toEqual([]); - expect(deleteResult.value.changes).toBe(2); + expect(deleteResult.rows).toEqual([]); + expect(deleteResult.changes).toBe(2); sqlite[Symbol.dispose](); }); @@ -81,9 +78,8 @@ describe("createBetterSqliteDriver", () => { sqlite.exec(sql`insert into t (data) values (${"foo"});`); const exported = sqlite.export(); - assert(exported.ok); - expect(exported.value).toBeInstanceOf(Uint8Array); - expect(exported.value.length).toBeGreaterThan(0); + expect(exported).toBeInstanceOf(Uint8Array); + expect(exported.length).toBeGreaterThan(0); sqlite[Symbol.dispose](); }); @@ -113,12 +109,11 @@ describe("createBetterSqliteDriver", () => { // Execute the same query twice — both should succeed via cached statement const insert1 = sqlite.exec(sql`insert into t (name) values (${"A"});`); const insert2 = sqlite.exec(sql`insert into t (name) values (${"B"});`); - assert(insert1.ok); - assert(insert2.ok); + expect(insert1.changes).toBe(1); + expect(insert2.changes).toBe(1); const rows = sqlite.exec(sql`select name from t order by id;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ name: "A" }, { name: "B" }]); + expect(rows.rows).toEqual([{ name: "A" }, { name: "B" }]); sqlite[Symbol.dispose](); }); diff --git a/packages/web/test/Sqlite.test.ts b/packages/web/test/Sqlite.test.ts index adc3acf39..de89afbc9 100644 --- a/packages/web/test/Sqlite.test.ts +++ b/packages/web/test/Sqlite.test.ts @@ -65,8 +65,7 @@ describe("createWasmSqliteDriver", () => { sqlite.exec(sql`create table t (data text);`); sqlite.exec(sql`insert into t (data) values (${"hello"});`); const rows = sqlite.exec(sql`select * from t;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ data: "hello" }]); + expect(rows.rows).toEqual([{ data: "hello" }]); sqlite[Symbol.dispose](); }); @@ -84,9 +83,8 @@ describe("createWasmSqliteDriver", () => { sqlite.exec(sql`insert into t (name) values (${"Bob"});`); const deleteResult = sqlite.exec(sql`delete from t;`); - assert(deleteResult.ok); - expect(deleteResult.value.rows).toEqual([]); - expect(deleteResult.value.changes).toBe(2); + expect(deleteResult.rows).toEqual([]); + expect(deleteResult.changes).toBe(2); sqlite[Symbol.dispose](); }); @@ -105,8 +103,7 @@ describe("createWasmSqliteDriver", () => { sqlite.exec(sql.prepared`insert into t (name) values (${"B"});`); const rows = sqlite.exec(sql.prepared`select name from t order by id;`); - assert(rows.ok); - expect(rows.value.rows).toEqual([{ name: "A" }, { name: "B" }]); + expect(rows.rows).toEqual([{ name: "A" }, { name: "B" }]); sqlite[Symbol.dispose](); }); @@ -123,9 +120,8 @@ describe("createWasmSqliteDriver", () => { sqlite.exec(sql`insert into t (data) values (${"foo"});`); const exported = sqlite.export(); - assert(exported.ok); - expect(exported.value).toBeInstanceOf(Uint8Array); - expect(exported.value.length).toBeGreaterThan(0); + expect(exported).toBeInstanceOf(Uint8Array); + expect(exported.length).toBeGreaterThan(0); sqlite[Symbol.dispose](); }); From 5c6e4c099b4c39bc01ad3441735598954a4da033 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 23:12:44 +0100 Subject: [PATCH 46/60] Add getOk for never-error Results Add getOk to Result utilities to safely extract values from Result with an assertion guard. Add tests covering getOk success path, type-level constraints, and runtime invariant violation. Include getOrNull tests to keep Result extraction helpers consistently covered. (cherry picked from commit b9fcb47d7de5dbd99732e85346979813f1f14226) --- packages/common/src/Result.ts | 17 ++++++++++++++- packages/common/test/Result.test.ts | 32 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/common/src/Result.ts b/packages/common/src/Result.ts index f99fa4237..e454eb9e8 100644 --- a/packages/common/src/Result.ts +++ b/packages/common/src/Result.ts @@ -10,6 +10,7 @@ import { isNonEmptyArray, type NonEmptyReadonlyArray, } from "./Array.js"; +import { assert } from "./Assert.js"; import type { UnknownError } from "./Error.js"; import type { Lazy } from "./Function.js"; import { exhaustiveCheck } from "./Function.js"; @@ -199,7 +200,9 @@ import type { Typed } from "./Type.js"; * switch (error.type) { * case "TimestampError": * console.error(error.error.stack); // Log preserved stack trace - * showToast("Timestamp error. Your computer clock appears to be incorrect."); + * showToast( + * "Timestamp error. Your computer clock appears to be incorrect.", + * ); * break; * case "SyncError": * showToast("Sync failed. Retrying..."); @@ -379,6 +382,18 @@ export const getOrThrow = (result: Result): T => { export const getOrNull = (result: Result): T | null => result.ok ? result.value : null; +/** + * Extracts the value from a {@link Result} whose error type is `never`. + * + * This is useful when the type system guarantees the result cannot fail (for + * example `Result`), avoiding impossible `if (!result.ok)` branches + * at call sites. + */ +export const getOk = (result: Result): T => { + assert(result.ok, "Expected Ok result."); + return result.value; +}; + /** * Wraps a synchronous function that may throw, returning a {@link Result}. * diff --git a/packages/common/test/Result.test.ts b/packages/common/test/Result.test.ts index c9d83d9d6..41859dc1e 100644 --- a/packages/common/test/Result.test.ts +++ b/packages/common/test/Result.test.ts @@ -16,6 +16,8 @@ import { anyResult, done, err, + getOk, + getOrNull, getOrThrow, isErr, isOk, @@ -131,6 +133,36 @@ describe("getOrThrow", () => { }); }); +describe("getOrNull", () => { + it("returns value for Ok", () => { + expect(getOrNull(ok(42))).toBe(42); + }); + + it("returns null for Err", () => { + expect(getOrNull(err("error"))).toBeNull(); + }); +}); + +describe("getOk", () => { + it("extracts value from Result with never error", () => { + const result = ok(42); + expect(getOk(result)).toBe(42); + }); + + it("rejects Result with possible error type", () => { + type IsAssignable = + Result extends Result ? true : false; + expectTypeOf().toEqualTypeOf(); + }); + + it("throws when invariant is violated at runtime", () => { + const invalid = err("fail") as unknown as Result; + expect(() => getOk(invalid)).toThrowErrorMatchingInlineSnapshot( + `[Error: Expected Ok result.]`, + ); + }); +}); + describe("trySync", () => { interface ParseError { readonly type: "ParseError"; From c89f42c7a98fabd86e1a50c4e0f2ba563d38754e Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 23:13:27 +0100 Subject: [PATCH 47/60] Use getOk in SQLite setup paths Replace manual Result unwrapping with getOk in DB and relay SQLite initialization paths. Simplify dependent code by working with unwrapped sqlite values directly. Update shared test deps helper to align with the same unwrapping pattern. (cherry picked from commit af2e3f6252fe09da16786f9bb2d4033b48389112) --- packages/nodejs/src/local-first/Relay.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/nodejs/src/local-first/Relay.ts b/packages/nodejs/src/local-first/Relay.ts index ba6a9c370..c57a08e79 100644 --- a/packages/nodejs/src/local-first/Relay.ts +++ b/packages/nodejs/src/local-first/Relay.ts @@ -4,11 +4,12 @@ import { createRelation, createSqlite, type CreateSqliteDriverDep, + getOk, isPromiseLike, + Name, ok, OwnerId, type RandomDep, - Name, type Task, type TimingSafeEqualDep, Uint8Array, @@ -27,8 +28,8 @@ import { import { existsSync } from "fs"; import { createServer } from "http"; import { WebSocket, WebSocketServer } from "ws"; -import { createBetterSqliteDriver } from "../Sqlite.js"; import { createTimingSafeEqual } from "../Crypto.js"; +import { createBetterSqliteDriver } from "../Sqlite.js"; export interface NodeJsRelayConfig extends RelayConfig { /** The port number for the HTTP server. */ @@ -76,9 +77,8 @@ export const startRelay = const dbFileExists = existsSync(`${name}.db`); - const sqliteResult = await stack.use(createSqlite(name)); - if (!sqliteResult.ok) return sqliteResult; - const deps = { ..._run.deps, sqlite: sqliteResult.value }; + const sqlite = getOk(await stack.use(createSqlite(name))); + const deps = { ..._run.deps, sqlite }; if (!dbFileExists) { createBaseSqliteStorageTables(deps); From def62918e0b4fa70cd0288463120651fb3d7a2d3 Mon Sep 17 00:00:00 2001 From: Miccy Date: Mon, 16 Feb 2026 02:26:26 +0100 Subject: [PATCH 48/60] chore(sync): resolve common-v8 merge conflicts and verify fixes --- biome.json | 10 +---- bun.lock | 22 +++++----- packages/common/src/Worker.ts | 2 +- packages/common/src/local-first/Evolu.ts | 2 +- packages/common/src/local-first/Protocol.ts | 10 +++-- packages/common/src/local-first/Relay.ts | 20 ++++----- packages/common/src/local-first/Schema.ts | 34 +++++++-------- packages/common/src/local-first/Storage.ts | 42 ++++++++++++------- packages/common/src/local-first/Sync.ts | 10 +---- packages/common/test/_deps.nodejs.ts | 3 -- .../common/test/local-first/Worker.test.ts | 15 +++++-- packages/common/test/local-first/_utils.ts | 4 +- packages/nodejs/src/Worker.ts | 2 +- packages/nodejs/src/local-first/Relay.ts | 10 ++--- .../src/exports/bare-op-sqlite.ts | 6 ++- packages/react-native/src/shared.ts | 2 +- 16 files changed, 101 insertions(+), 93 deletions(-) diff --git a/biome.json b/biome.json index cf69eb077..1254b377a 100644 --- a/biome.json +++ b/biome.json @@ -30,6 +30,7 @@ "noExplicitAny": "off", "noImplicitAnyLet": "off", "useIterableCallbackReturn": "off", + "noImportCycles": "error", "noThenProperty": "off", "noAssignInExpressions": "off" }, @@ -54,14 +55,7 @@ "security": { "noDangerouslySetInnerHtml": "warn" }, - "nursery": { - "noImportCycles": { - "level": "error", - "options": { - "ignoreTypes": true - } - } - } + "nursery": {} } }, "javascript": { diff --git a/bun.lock b/bun.lock index 8d13d7e99..971e5fa3e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,14 @@ "": { "name": "@evolu/monorepo", "devDependencies": { - "@biomejs/biome": "2.3.15", + "@biomejs/biome": "2.4.0", "@changesets/cli": "^2.29.8", "@types/webpack": "^5.28.5", "@vitest/browser": "^4.0.17", "@vitest/browser-playwright": "^4.0.17", "@vitest/coverage-v8": "^4.0.17", "rimraf": "^6.1.2", - "turbo": "^2.8.8", + "turbo": "^2.8.9", "typedoc": "^0.28.17", "typedoc-plugin-markdown": "^4.9.0", "typescript": "^5.9.3", @@ -694,23 +694,23 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], - "@biomejs/biome": ["@biomejs/biome@2.3.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.15", "@biomejs/cli-darwin-x64": "2.3.15", "@biomejs/cli-linux-arm64": "2.3.15", "@biomejs/cli-linux-arm64-musl": "2.3.15", "@biomejs/cli-linux-x64": "2.3.15", "@biomejs/cli-linux-x64-musl": "2.3.15", "@biomejs/cli-win32-arm64": "2.3.15", "@biomejs/cli-win32-x64": "2.3.15" }, "bin": { "biome": "bin/biome" } }, "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g=="], + "@biomejs/biome": ["@biomejs/biome@2.4.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.0", "@biomejs/cli-darwin-x64": "2.4.0", "@biomejs/cli-linux-arm64": "2.4.0", "@biomejs/cli-linux-arm64-musl": "2.4.0", "@biomejs/cli-linux-x64": "2.4.0", "@biomejs/cli-linux-x64-musl": "2.4.0", "@biomejs/cli-win32-arm64": "2.4.0", "@biomejs/cli-win32-x64": "2.4.0" }, "bin": { "biome": "bin/biome" } }, "sha512-iluT61cORUDIC5i/y42ljyQraCemmmcgbMLLCnYO+yh+2hjTmcMFcwY8G0zTzWCsPb3t3AyKc+0t/VuhPZULUg=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-L+YpOtPSuU0etomfvFTPWRsa7+8ejaJL3yaROEoT/96HDJbR6OsvZQk0C8JUYou+XFdP+JcGxqZknkp4n934RA=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Aq+S7ffpb5ynTyLgtnEjG+W6xuTd2F7FdC7J6ShpvRhZwJhjzwITGF9vrqoOnw0sv1XWkt2Q1Rpg+hleg/Xg7Q=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-u2p54IhvNAWB+h7+rxCZe3reNfQYFK+ppDw+q0yegrGclFYnDPZAntv/PqgUacpC3uxTeuWFgWW7RFe3lHuxOA=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1rhDUq8sf7xX3tg7vbnU3WVfanKCKi40OXc4VleBMzRStmQHdeBY46aFP6VdwEomcVjyNiu+Zcr3LZtAdrZrjQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.15", "", { "os": "linux", "cpu": "x64" }, "sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WVFOhsnzhrbMGOSIcB9yFdRV2oG2KkRRhIZiunI9gJqSU3ax9ErdnTxRfJUxZUI9NbzVxC60OCXNcu+mXfF/Tw=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.15", "", { "os": "linux", "cpu": "x64" }, "sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Omo0xhl63z47X+CrE5viEWKJhejJyndl577VoXg763U/aoATrK3r5+8DPh02GokWPeODX1Hek00OtjjooGan9w=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-aqRwW0LJLV1v1NzyLvLWQhdLmDSAV1vUh+OBdYJaa8f28XBn5BZavo+WTfqgEzALZxlNfBmu6NGO6Al3MbCULw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.15", "", { "os": "win32", "cpu": "x64" }, "sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-g47s+V+OqsGxbSZN3lpav6WYOk0PIc3aCBAq+p6dwSynL3K5MA6Cg6nkzDOlu28GEHwbakW+BllzHCJCxnfK5Q=="], "@blazejkustra/react-native-alert": ["@blazejkustra/react-native-alert@1.0.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-bgvKlnhfS39vz38BSBdHk1smVME0Nf5tJEzgoQOIPpci8KuTBcOORa93B2PV3/S5a0QkR1d8nW2yw76HDj6zqQ=="], diff --git a/packages/common/src/Worker.ts b/packages/common/src/Worker.ts index 4d993026f..c6c657fef 100644 --- a/packages/common/src/Worker.ts +++ b/packages/common/src/Worker.ts @@ -5,8 +5,8 @@ */ import type { Brand } from "./Brand.js"; -import type { GlobalErrorScope } from "./Error.js"; import type { ConsoleDep, ConsoleStoreOutputEntryDep } from "./Console.js"; +import type { GlobalErrorScope } from "./Error.js"; /** * Platform-agnostic Worker. diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index 334b0a3d1..2f498cd75 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -770,7 +770,7 @@ export const createEvolu = errorStore.set(createUnknownError(error)); }; - const dbSchema = evoluSchemaToDbSchema(schema as EvoluSchema, _indexes); + const dbSchema = evoluSchemaToDbSchema(schema, _indexes); const dbWorker = createDbWorkerClient(deps, name, setUnknownError); const storeAppOwner = async (nextAppOwner: AppOwner): Promise => { diff --git a/packages/common/src/local-first/Protocol.ts b/packages/common/src/local-first/Protocol.ts index 1634324eb..b20ad7c15 100644 --- a/packages/common/src/local-first/Protocol.ts +++ b/packages/common/src/local-first/Protocol.ts @@ -181,7 +181,6 @@ import { Packr } from "msgpackr"; import { isNonEmptyArray, type NonEmptyReadonlyArray } from "../Array.js"; import { assert } from "../Assert.js"; import type { Brand } from "../Brand.js"; -import type { ConsoleDep } from "../Console.js"; import { type Buffer, bytesToHex, @@ -190,6 +189,7 @@ import { hexToBytes, utf8ToBytes, } from "../Buffer.js"; +import type { ConsoleDep } from "../Console.js"; import { createPadmePadding, type DecryptWithXChaCha20Poly1305Error, @@ -994,7 +994,9 @@ export const applyProtocolMessageAsClient = if (isNonEmptyArray(messages)) { try { - const result = await run(storage.writeMessages(ownerIdBytes, messages)); + const result = await run( + storage.writeMessages(ownerIdBytes, messages), + ); // Quota errors are handled by Storage; protocol just stops syncing. if (!result.ok) return ok({ type: "NoResponse" }); } catch (error) { @@ -1152,7 +1154,9 @@ export const applyProtocolMessageAsRelay = } try { - const result = await run(storage.writeMessages(ownerIdBytes, messages)); + const result = await run( + storage.writeMessages(ownerIdBytes, messages), + ); if (!result.ok) { const message = createProtocolMessageBuffer(ownerId, { diff --git a/packages/common/src/local-first/Relay.ts b/packages/common/src/local-first/Relay.ts index 62eaf1d52..825efbae4 100644 --- a/packages/common/src/local-first/Relay.ts +++ b/packages/common/src/local-first/Relay.ts @@ -10,8 +10,8 @@ import { isNonEmptyArray, mapArray, } from "../Array.js"; -import type { TimingSafeEqualDep } from "../Crypto.js"; import { assert } from "../Assert.js"; +import type { TimingSafeEqualDep } from "../Crypto.js"; import { createInstances } from "../Instances.js"; import type { Result } from "../Result.js"; import { err, ok } from "../Result.js"; @@ -227,23 +227,17 @@ export const createRelaySqliteStorage = firstTimestamp, lastTimestamp, ); - - { - sqliteStorageBase.insertTimestamp( - ownerIdBytes, - timestamp, - strategy, - ); - } - - { - deps.sqlite.exec(sql` + sqliteStorageBase.insertTimestamp( + ownerIdBytes, + timestamp, + strategy, + ); + deps.sqlite.exec(sql` insert into evolu_message ("ownerId", "timestamp", "change") values (${ownerIdBytes}, ${timestamp}, ${change}) on conflict do nothing; `); - } } updateOwnerUsage(deps)( diff --git a/packages/common/src/local-first/Schema.ts b/packages/common/src/local-first/Schema.ts index 10b741c64..0a1157adc 100644 --- a/packages/common/src/local-first/Schema.ts +++ b/packages/common/src/local-first/Schema.ts @@ -14,25 +14,25 @@ import { } from "../Object.js"; import { type SafeSql, - sql, SqliteBoolean, type SqliteDep, type SqliteQuery, type SqliteQueryOptions, - SqliteValue, + type SqliteValue, + sql, } from "../Sqlite.js"; import type { InferType } from "../Type.js"; import { array, DateIso, type Id, - IdBytes, + type IdBytes, nullOr, object, record, - set, type StandardSchemaV1, String, + set, } from "../Type.js"; import type { Simplify } from "../Types.js"; import type { AppOwner } from "./Owner.js"; @@ -40,7 +40,7 @@ import { OwnerId } from "./Owner.js"; import type { Query, Row } from "./Query.js"; import { serializeQuery } from "./Query.js"; import type { CrdtMessage, DbChange } from "./Storage.js"; -import { TimestampBytes } from "./Timestamp.js"; +import type { TimestampBytes } from "./Timestamp.js"; /** Any {@link StandardSchemaV1}. */ export type AnyStandardSchemaV1 = StandardSchemaV1; @@ -449,10 +449,10 @@ export const getDbSchema = ({ allIndexes = false }: { allIndexes?: boolean } = {}): DbSchema => { const tables = createRecord>(); - const tableAndColumnInfoRows = deps.sqlite.exec<{ - tableName: string; - columnName: string; - }>(sql` + const tableAndColumnInfoRows = deps.sqlite.exec<{ + tableName: string; + columnName: string; + }>(sql` select sqlite_master.name as tableName, table_info.name as columnName @@ -461,18 +461,18 @@ export const getDbSchema = join pragma_table_info(sqlite_master.name) as table_info; `); - tableAndColumnInfoRows.rows.forEach(({ tableName, columnName }) => { - (tables[tableName] ??= new Set()).add(columnName); - }); + tableAndColumnInfoRows.rows.forEach(({ tableName, columnName }) => { + (tables[tableName] ??= new Set()).add(columnName); + }); - const indexesRows = deps.sqlite.exec<{ name: string; sql: string | null }>( - allIndexes - ? sql` + const indexesRows = deps.sqlite.exec<{ name: string; sql: string | null }>( + allIndexes + ? sql` select name, sql from sqlite_master where type = 'index' and name not like 'sqlite_%'; ` - : sql` + : sql` select name, sql from sqlite_master where @@ -480,7 +480,7 @@ export const getDbSchema = and name not like 'sqlite_%' and name not like 'evolu_%'; `, - ); + ); const indexes = indexesRows.rows.flatMap((row): Array => { if (row.sql == null) return []; diff --git a/packages/common/src/local-first/Storage.ts b/packages/common/src/local-first/Storage.ts index 928893700..1f53aa344 100644 --- a/packages/common/src/local-first/Storage.ts +++ b/packages/common/src/local-first/Storage.ts @@ -15,7 +15,7 @@ import type { RandomDep } from "../Random.js"; import type { Result } from "../Result.js"; import { err, ok } from "../Result.js"; import type { SqliteDep } from "../Sqlite.js"; -import { sql, SqliteValue } from "../Sqlite.js"; +import { SqliteValue, sql } from "../Sqlite.js"; import type { Task } from "../Task.js"; import type { InferType, Int64String, Typed, TypeError } from "../Type.js"; import { @@ -30,10 +30,19 @@ import { String, } from "../Type.js"; import type { Awaitable } from "../Types.js"; -import type { Owner, OwnerError, OwnerIdBytes } from "./Owner.js"; -import { OwnerId, OwnerWriteKey } from "./Owner.js"; +import type { + Owner, + OwnerError, + OwnerId, + OwnerIdBytes, + OwnerWriteKey, +} from "./Owner.js"; import { systemColumnsWithId } from "./Schema.js"; -import { orderTimestampBytes, Timestamp, TimestampBytes } from "./Timestamp.js"; +import { + orderTimestampBytes, + type Timestamp, + type TimestampBytes, +} from "./Timestamp.js"; export interface StorageConfig { /** @@ -167,7 +176,8 @@ export interface StorageDep { /** Error when storage or billing quota is exceeded. */ export interface StorageQuotaError - extends OwnerError, Typed<"StorageQuotaError"> {} + extends OwnerError, + Typed<"StorageQuotaError"> {} /** * A cryptographic hash used for efficiently comparing collections of @@ -263,7 +273,8 @@ export const ValidDbChangeValues = /*#__PURE__*/ brand( ); export type ValidDbChangeValues = typeof ValidDbChangeValues.Type; -export interface ValidDbChangeValuesError extends TypeError<"ValidDbChangeValues"> { +export interface ValidDbChangeValuesError + extends TypeError<"ValidDbChangeValues"> { readonly invalidColumns: ReadonlyArray; } @@ -313,15 +324,16 @@ export interface DbChange extends InferType {} * users, and when it goes down, nothing happens, because it will be * synchronized later. */ -export interface BaseSqliteStorage extends Pick< - Storage, - | "getSize" - | "fingerprint" - | "fingerprintRanges" - | "findLowerBound" - | "iterate" - | "deleteOwner" -> { +export interface BaseSqliteStorage + extends Pick< + Storage, + | "getSize" + | "fingerprint" + | "fingerprintRanges" + | "findLowerBound" + | "iterate" + | "deleteOwner" + > { /** Inserts a timestamp for an owner into the skiplist-based storage. */ readonly insertTimestamp: ( ownerId: OwnerIdBytes, diff --git a/packages/common/src/local-first/Sync.ts b/packages/common/src/local-first/Sync.ts index 15fde944c..3b8d98d7a 100644 --- a/packages/common/src/local-first/Sync.ts +++ b/packages/common/src/local-first/Sync.ts @@ -787,9 +787,7 @@ const applyColumnChange = set ${sql.identifier(column)} = ${value} where not exists (select 1 from existingTimestamp); `); - - { - deps.sqlite.exec(sql.prepared` + deps.sqlite.exec(sql.prepared` insert into evolu_history ("ownerId", "table", "id", "column", "value", "timestamp") values @@ -803,7 +801,6 @@ const applyColumnChange = ) on conflict do nothing; `); - } }; /** @@ -839,9 +836,7 @@ export const tryApplyQuarantinedMessages = row.value, row.timestamp, ); - - { - deps.sqlite.exec(sql` + deps.sqlite.exec(sql` delete from evolu_message_quarantine where "ownerId" = ${row.ownerId} @@ -850,7 +845,6 @@ export const tryApplyQuarantinedMessages = and "id" = ${row.id} and "column" = ${row.column}; `); - } } }; diff --git a/packages/common/test/_deps.nodejs.ts b/packages/common/test/_deps.nodejs.ts index cc1d24135..a9e9bf209 100644 --- a/packages/common/test/_deps.nodejs.ts +++ b/packages/common/test/_deps.nodejs.ts @@ -58,9 +58,6 @@ export const testCreateRunWithSqliteAndRelayStorage = async ( ...run.deps, timingSafeEqual: testTimingSafeEqual, })({ - onStorageError: (error) => { - throw new Error(error.type); - }, isOwnerWithinQuota: lazyTrue, ...config, }); diff --git a/packages/common/test/local-first/Worker.test.ts b/packages/common/test/local-first/Worker.test.ts index 7e902261d..322733fa1 100644 --- a/packages/common/test/local-first/Worker.test.ts +++ b/packages/common/test/local-first/Worker.test.ts @@ -64,7 +64,10 @@ test("runEvoluWorkerScope forwards global errors to registered tab port", () => const createMessagePort: CreateMessagePort = ( nativePort: NativeMessagePort, ): MessagePort => { - if (nativePort === tabPort.native) + if ( + (nativePort as NativeMessagePort) === + (tabPort.native as NativeMessagePort) + ) return tabPort.port as unknown as MessagePort; throw new Error("Unexpected native port"); }; @@ -100,9 +103,15 @@ test("runEvoluWorkerScope routes InitEvolu port to db worker runner", () => { const createMessagePort: CreateMessagePort = ( nativePort: NativeMessagePort, ): MessagePort => { - if (nativePort === dbPort.native) + if ( + (nativePort as NativeMessagePort) === + (dbPort.native as NativeMessagePort) + ) return dbPort.port as unknown as MessagePort; - if (nativePort === brokerPort.native) + if ( + (nativePort as NativeMessagePort) === + (brokerPort.native as NativeMessagePort) + ) return brokerPort.port as unknown as MessagePort; throw new Error("Unexpected native port"); }; diff --git a/packages/common/test/local-first/_utils.ts b/packages/common/test/local-first/_utils.ts index 18eeaab31..d033ba288 100644 --- a/packages/common/test/local-first/_utils.ts +++ b/packages/common/test/local-first/_utils.ts @@ -1,11 +1,11 @@ -import { DbSchema, getDbSchema } from "../../src/local-first/Schema.js"; +import { type DbSchema, getDbSchema } from "../../src/local-first/Schema.js"; import type { CrdtMessage } from "../../src/local-first/Storage.js"; import { DbChange } from "../../src/local-first/Storage.js"; import { createTimestamp } from "../../src/local-first/Timestamp.js"; import type { SqliteDep } from "../../src/Sqlite.js"; import { sql } from "../../src/Sqlite.js"; import { Millis } from "../../src/Time.js"; -import { Id } from "../../src/Type.js"; +import type { Id } from "../../src/Type.js"; export interface DbSnapshot { readonly schema: DbSchema; diff --git a/packages/nodejs/src/Worker.ts b/packages/nodejs/src/Worker.ts index 442974568..bdce4fb69 100644 --- a/packages/nodejs/src/Worker.ts +++ b/packages/nodejs/src/Worker.ts @@ -121,7 +121,7 @@ const wrap = ( else native.postMessage(message, toTransferList(transfer)); }, onMessage: null, - native: native as unknown as NativeMessagePort, + native: native as unknown as NativeMessagePort, [Symbol.dispose]: () => { port.onMessage = null; native.off("message", onNativeMessage); diff --git a/packages/nodejs/src/local-first/Relay.ts b/packages/nodejs/src/local-first/Relay.ts index c57a08e79..28e9de3f4 100644 --- a/packages/nodejs/src/local-first/Relay.ts +++ b/packages/nodejs/src/local-first/Relay.ts @@ -1,22 +1,22 @@ import { + type CreateSqliteDriverDep, callback, createRandom, createRelation, createSqlite, - type CreateSqliteDriverDep, getOk, isPromiseLike, - Name, + type OwnerId, ok, - OwnerId, type RandomDep, + SimpleName, type Task, type TimingSafeEqualDep, Uint8Array, } from "@evolu/common"; import { - applyProtocolMessageAsRelay, type ApplyProtocolMessageAsRelayOptions, + applyProtocolMessageAsRelay, createBaseSqliteStorageTables, createRelaySqliteStorage, createRelayStorageTables, @@ -67,7 +67,7 @@ export const createRelayDeps = (): RelayDeps => ({ export const startRelay = ({ port = 443, - name = Name.orThrow("evolu-relay"), + name = SimpleName.orThrow("evolu-relay"), isOwnerAllowed, isOwnerWithinQuota, }: NodeJsRelayConfig): Task => diff --git a/packages/react-native/src/exports/bare-op-sqlite.ts b/packages/react-native/src/exports/bare-op-sqlite.ts index 1fa5f6542..ecf6dddd8 100644 --- a/packages/react-native/src/exports/bare-op-sqlite.ts +++ b/packages/react-native/src/exports/bare-op-sqlite.ts @@ -10,6 +10,7 @@ import type { ReloadApp } from "@evolu/common"; import { DevSettings } from "react-native"; import { SensitiveInfo } from "react-native-sensitive-info"; import { createEvoluDeps, createSharedLocalAuth } from "../shared.js"; +import { createOpSqliteDriver } from "../sqlite-drivers/createOpSqliteDriver.js"; const reloadApp: ReloadApp = () => { if (process.env.NODE_ENV === "development") { @@ -19,6 +20,9 @@ const reloadApp: ReloadApp = () => { } }; -export const evoluReactNativeDeps = createEvoluDeps({ reloadApp }); +export const evoluReactNativeDeps = createEvoluDeps({ + createSqliteDriver: createOpSqliteDriver, + reloadApp, +}); export const localAuth = createSharedLocalAuth(SensitiveInfo); diff --git a/packages/react-native/src/shared.ts b/packages/react-native/src/shared.ts index 87ed5d8f2..70e0c752f 100644 --- a/packages/react-native/src/shared.ts +++ b/packages/react-native/src/shared.ts @@ -1,7 +1,7 @@ import { + type CreateSqliteDriverDep, createLocalAuth, createRandomBytes, - type CreateSqliteDriverDep, type LocalAuth, type ReloadAppDep, type SecureStorage, From f79c094d10b35b011fceadf4a57c3ac0e0a80df3 Mon Sep 17 00:00:00 2001 From: Miccy Date: Mon, 16 Feb 2026 03:21:57 +0100 Subject: [PATCH 49/60] feat: Introduce console dependency and logging level control for worker and sync operations, and update dependencies. --- bun.lock | 136 ++---------------- packages/common/src/local-first/Protocol.ts | 28 ++-- packages/common/src/local-first/Relay.ts | 4 +- packages/common/src/local-first/Storage.ts | 24 +++- packages/common/src/local-first/Sync.ts | 28 +++- packages/common/src/local-first/Worker.ts | 21 ++- .../common/test/local-first/Worker.test.ts | 9 ++ packages/web/src/local-first/DbWorker.ts | 3 + 8 files changed, 100 insertions(+), 153 deletions(-) diff --git a/bun.lock b/bun.lock index 971e5fa3e..9d0dc9d1f 100644 --- a/bun.lock +++ b/bun.lock @@ -1594,7 +1594,7 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], @@ -1716,7 +1716,7 @@ "bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], - "bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], + "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -2862,7 +2862,7 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - "rimraf": ["rimraf@6.1.2", "", { "dependencies": { "glob": "^13.0.0", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g=="], + "rimraf": ["rimraf@6.1.3", "", { "dependencies": { "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA=="], "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], @@ -2898,7 +2898,7 @@ "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="], - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], @@ -3066,7 +3066,7 @@ "suppressed-error": ["suppressed-error@1.0.3", "", { "dependencies": { "define-data-property": "^1.1.1", "define-properties": "^1.2.1", "es-abstract": "^1.22.3", "es-errors": "^1.1.0", "function-bind": "^1.1.2", "globalthis": "^1.0.3", "has-property-descriptors": "^1.0.1", "set-function-name": "^2.0.1" } }, "sha512-6+ZiCVUmDLFRyYRswTrDTYWaM/IT01W/cqQBLnnyg8T0njVrWj3tP+EXFevXk6qK61yDXnmZsOFVzFfYoUy/KA=="], - "svelte": ["svelte@5.51.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-r8r6p+NFC2ckAkxW4lqpGs1AZWBi5Y+TbJMmAglqSbokN5UWkDsKKkybfGBKXd8yYMri7KJ2L78fO9SO+NOelA=="], + "svelte": ["svelte@5.51.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-AqApqNOxVS97V4Ko9UHTHeSuDJrwauJhZpLDs1gYD8Jk48ntCSWD7NxKje+fnGn5Ja1O3u2FzQZHPdifQjXe3w=="], "svelte-check": ["svelte-check@4.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-gB3FdEPb8tPO3Y7Dzc6d/Pm/KrXAhK+0Fk+LkcysVtupvAh6Y/IrBCEZNupq57oh0hcwlxCUamu/rq7GtvfSEg=="], @@ -3076,7 +3076,7 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], - "tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="], + "tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="], "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], @@ -3272,7 +3272,7 @@ "webpack": ["webpack@5.105.2", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.19.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.5.1", "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw=="], - "webpack-sources": ["webpack-sources@3.3.3", "", {}, "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg=="], + "webpack-sources": ["webpack-sources@3.3.4", "", {}, "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q=="], "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], @@ -3362,16 +3362,12 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "@angular-devkit/core/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - "@angular-devkit/core/ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "@angular-devkit/core/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@angular/build/@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="], - "@angular/build/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "@angular/build/vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="], "@angular/build/watchpack": ["watchpack@2.5.0", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-e6vZvY6xboSwLz2GD36c16+O/2Z6fKvIf4pOXptw2rY9MVwE/TXc6RGqxD3I3x0a28lwBY7DE+76uTPSsBrrCA=="], @@ -3440,8 +3436,6 @@ "@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="], - "@expo/config/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], - "@expo/devcert/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "@expo/fingerprint/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -3452,8 +3446,6 @@ "@expo/prebuild-config/@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="], - "@expo/prebuild-config/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="], - "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], @@ -3642,8 +3634,6 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], - "jest-haste-map/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "jest-haste-map/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], "jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -3748,6 +3738,8 @@ "sharp-ico/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], @@ -3784,8 +3776,6 @@ "unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], - "vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], - "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "webpack/es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], @@ -3810,14 +3800,10 @@ "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], - "@angular-devkit/core/ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - "@angular/build/@babel/core/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "@angular/build/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@angular/build/vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], - "@angular/build/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "@angular/compiler-cli/@babel/core/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -4098,58 +4084,6 @@ "test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], - - "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], - - "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - - "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], - - "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], - - "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], - - "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], - - "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], - - "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], - - "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], - - "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], - - "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], - - "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], - - "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], - - "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], - - "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], - - "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], - - "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], - - "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], - - "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], - - "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], - - "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], - - "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], - - "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], - - "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], - - "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - "workbox-build/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "workbox-build/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], @@ -4158,58 +4092,6 @@ "workbox-build/rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "@angular/build/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], - - "@angular/build/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], - - "@angular/build/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - - "@angular/build/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], - - "@angular/build/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], - - "@angular/build/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], - - "@angular/build/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], - - "@angular/build/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], - - "@angular/build/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], - - "@angular/build/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], - - "@angular/build/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], - - "@angular/build/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], - - "@angular/build/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], - - "@angular/build/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], - - "@angular/build/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], - - "@angular/build/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], - - "@angular/build/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], - - "@angular/build/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], - - "@angular/build/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], - - "@angular/build/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], - - "@angular/build/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], - - "@angular/build/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], - - "@angular/build/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], - - "@angular/build/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], - - "@angular/build/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], - - "@angular/build/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - "@angular/compiler-cli/yargs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "@angular/compiler-cli/yargs/cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], diff --git a/packages/common/src/local-first/Protocol.ts b/packages/common/src/local-first/Protocol.ts index b20ad7c15..050657524 100644 --- a/packages/common/src/local-first/Protocol.ts +++ b/packages/common/src/local-first/Protocol.ts @@ -540,13 +540,14 @@ export const createProtocolMessageForSync = const size = deps.storage.getSize(ownerIdBytes); - splitRange(deps)( + const didSplitRange = splitRange(deps)( ownerIdBytes, NonNegativeInt.orThrow(0), size, InfiniteUpperBound, buffer, ); + if (!didSplitRange) return null; return buffer.unwrap(); }; @@ -1394,13 +1395,17 @@ const sync = } else { if (output.canSplitRange()) { coalesceSkipsBeforeAdd(); - splitRange(deps)( - ownerIdBytes, - lower, - upper, - currentUpperBound, - output, - ); + if ( + !splitRange(deps)( + ownerIdBytes, + lower, + upper, + currentUpperBound, + output, + ) + ) { + return err(ProtocolErrorCode.SyncError); + } } else { return addFingerprintForRemainingRange(upper) ? ok(true) @@ -1522,7 +1527,7 @@ const splitRange = upper: NonNegativeInt, upperBound: RangeUpperBound, buffer: ProtocolMessageBuffer, - ): void => { + ): boolean => { const itemCount = NonNegativeInt.orThrow(upper - lower); const buckets = computeBalancedBuckets(itemCount); @@ -1544,7 +1549,7 @@ const splitRange = ); buffer.addRange(range); - return; + return true; } // Check Storage.ts `fingerprint` and `fingerprintRanges` docs. @@ -1565,7 +1570,7 @@ const splitRange = ); } catch (error) { deps.console.error(error); - return; + return false; } const rangesToUse = @@ -1574,6 +1579,7 @@ const splitRange = for (const range of rangesToUse) { buffer.addRange(range); } + return true; }; const decodeRanges = (buffer: Buffer): ReadonlyArray => { diff --git a/packages/common/src/local-first/Relay.ts b/packages/common/src/local-first/Relay.ts index 825efbae4..62e528e47 100644 --- a/packages/common/src/local-first/Relay.ts +++ b/packages/common/src/local-first/Relay.ts @@ -254,7 +254,7 @@ export const createRelaySqliteStorage = if (!result.ok) { if (result.error.type === "AbortError") return ok(); - return err({ type: "StorageQuotaError", ownerId }); + return err(result.error); } return ok(); @@ -289,8 +289,6 @@ export const createRelaySqliteStorage = `); sqliteStorageBase.deleteOwner(ownerId); - - return ok(); }); }, }; diff --git a/packages/common/src/local-first/Storage.ts b/packages/common/src/local-first/Storage.ts index 1f53aa344..7a5ab2a20 100644 --- a/packages/common/src/local-first/Storage.ts +++ b/packages/common/src/local-first/Storage.ts @@ -1322,12 +1322,32 @@ const fingerprint = return zeroFingerprint; } + const getFingerprintFromRanges = ( + ranges: ReadonlyArray, + index: number, + ): Fingerprint => { + const range = ranges[index]; + if (range) return range.fingerprint; + throw new Error( + [ + "Missing fingerprint range", + `ownerId=${globalThis.Array.from(ownerId).join(",")}`, + `begin=${begin}`, + `end=${end}`, + `index=${index}`, + `rangesLength=${ranges.length}`, + ].join(" "), + ); + }; + if (begin === 0) { - return fingerprintRanges(deps)(ownerId, [end])[0].fingerprint; + const ranges = fingerprintRanges(deps)(ownerId, [end]); + return getFingerprintFromRanges(ranges, 0); } // We should have a param to skip the first result. - return fingerprintRanges(deps)(ownerId, [begin, end])[1].fingerprint; + const ranges = fingerprintRanges(deps)(ownerId, [begin, end]); + return getFingerprintFromRanges(ranges, 1); }; /** diff --git a/packages/common/src/local-first/Sync.ts b/packages/common/src/local-first/Sync.ts index 3b8d98d7a..536b87386 100644 --- a/packages/common/src/local-first/Sync.ts +++ b/packages/common/src/local-first/Sync.ts @@ -441,6 +441,7 @@ export interface ClientStorageDep { const createClientStorage = ( deps: ClockDep & + ConsoleDep & DbSchemaDep & GetSyncOwnerDep & RandomBytesDep & @@ -666,15 +667,30 @@ export const applyLocalOnlyChange = }; const applyMessages = - (deps: ClientStorageDep & ClockDep & DbSchemaDep & RandomDep & SqliteDep) => + ( + deps: ClientStorageDep & + ClockDep & + ConsoleDep & + DbSchemaDep & + RandomDep & + SqliteDep, + ) => (ownerId: OwnerId, messages: NonEmptyReadonlyArray): void => { const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId); - - const usage = getOwnerUsage(deps)( - ownerIdBytes, - timestampToTimestampBytes(firstInArray(messages).timestamp), + const firstMessageTimestamp = timestampToTimestampBytes( + firstInArray(messages).timestamp, ); - if (!usage.ok) return; + + const usage = getOwnerUsage(deps)(ownerIdBytes, firstMessageTimestamp); + if (!usage.ok) { + deps.console.error("[sync]", "applyMessages/getOwnerUsage failed", { + ownerId, + ownerIdBytes, + firstMessageTimestamp, + error: usage.error, + }); + return; + } let { firstTimestamp, lastTimestamp } = usage.value; diff --git a/packages/common/src/local-first/Worker.ts b/packages/common/src/local-first/Worker.ts index ae6f5b9b5..708465414 100644 --- a/packages/common/src/local-first/Worker.ts +++ b/packages/common/src/local-first/Worker.ts @@ -4,7 +4,7 @@ * @module */ -import type { ConsoleEntry, ConsoleLevel } from "../Console.js"; +import type { ConsoleDep, ConsoleEntry, ConsoleLevel } from "../Console.js"; import { exhaustiveCheck } from "../Function.js"; import { ok } from "../Result.js"; import type { Task } from "../Task.js"; @@ -59,16 +59,18 @@ export type EvoluTabOutput = export interface RunDbWorkerPortDep { readonly runDbWorkerPort: (config: { readonly name: SimpleName; + readonly consoleLevel: ConsoleLevel; readonly port: MessagePort; readonly brokerPort: MessagePort; }) => void; } export const runEvoluWorkerScope = - (deps: CreateMessagePortDep & RunDbWorkerPortDep) => + (deps: CreateMessagePortDep & RunDbWorkerPortDep & ConsoleDep) => (self: EvoluWorkerScope): void => { const tabPorts = new Set>(); const queuedTabOutputs: Array = []; + let consoleLevel = deps.console.getLevel(); const postTabOutput = (output: EvoluTabOutput): void => { if (tabPorts.size === 0) { @@ -89,6 +91,8 @@ export const runEvoluWorkerScope = const tabPort = deps.createMessagePort( message.port, ); + consoleLevel = message.consoleLevel; + deps.console.setLevel(consoleLevel); tabPorts.add(tabPort); if (queuedTabOutputs.length > 0) { @@ -106,7 +110,12 @@ export const runEvoluWorkerScope = DbWorkerLeaderOutput, DbWorkerLeaderInput >(message.port2); - deps.runDbWorkerPort({ name: message.name, port, brokerPort }); + deps.runDbWorkerPort({ + name: message.name, + consoleLevel, + port, + brokerPort, + }); break; } default: @@ -124,7 +133,11 @@ export const runEvoluWorkerScope = export const initEvoluWorker = ( self: EvoluWorkerScope, - ): Task => + ): Task< + void, + never, + CreateMessagePortDep & RunDbWorkerPortDep & ConsoleDep + > => (run) => { runEvoluWorkerScope(run.deps)(self); return ok(); diff --git a/packages/common/test/local-first/Worker.test.ts b/packages/common/test/local-first/Worker.test.ts index 322733fa1..97a8cff0c 100644 --- a/packages/common/test/local-first/Worker.test.ts +++ b/packages/common/test/local-first/Worker.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from "vitest"; +import { createConsole } from "../../src/Console.js"; import { createUnknownError } from "../../src/Error.js"; import type { DbWorkerInput, @@ -72,7 +73,11 @@ test("runEvoluWorkerScope forwards global errors to registered tab port", () => throw new Error("Unexpected native port"); }; + const console = createConsole(); + const setLevel = vi.spyOn(console, "setLevel"); + runEvoluWorkerScope({ + console, createMessagePort, runDbWorkerPort: vi.fn(), })(workerScope); @@ -87,6 +92,7 @@ test("runEvoluWorkerScope forwards global errors to registered tab port", () => const error = createUnknownError(new Error("boom")); workerScope.onError?.(error); + expect(setLevel).toHaveBeenCalledWith("debug"); expect(tabPort.sentMessages).toEqual([{ type: "EvoluError", error }]); }); @@ -99,6 +105,7 @@ test("runEvoluWorkerScope routes InitEvolu port to db worker runner", () => { DbWorkerLeaderInput >(); const runDbWorkerPort = vi.fn(); + const console = createConsole(); const createMessagePort: CreateMessagePort = ( nativePort: NativeMessagePort, @@ -117,6 +124,7 @@ test("runEvoluWorkerScope routes InitEvolu port to db worker runner", () => { }; runEvoluWorkerScope({ + console, createMessagePort, runDbWorkerPort, })(workerScope); @@ -133,6 +141,7 @@ test("runEvoluWorkerScope routes InitEvolu port to db worker runner", () => { expect(runDbWorkerPort).toHaveBeenCalledTimes(1); expect(runDbWorkerPort).toHaveBeenCalledWith({ name, + consoleLevel: "log", port: dbPort.port, brokerPort: brokerPort.port, }); diff --git a/packages/web/src/local-first/DbWorker.ts b/packages/web/src/local-first/DbWorker.ts index b1390d9eb..634c63e3b 100644 --- a/packages/web/src/local-first/DbWorker.ts +++ b/packages/web/src/local-first/DbWorker.ts @@ -1,4 +1,5 @@ import type { + ConsoleLevel, MessagePort, SafeSql, SimpleName, @@ -144,6 +145,7 @@ const releaseSharedDb = (dbName: string): void => { export const runWebDbWorkerPort = (config: { readonly name: SimpleName; + readonly consoleLevel?: ConsoleLevel; readonly port: MessagePort; readonly brokerPort: MessagePort; }): void => { @@ -153,6 +155,7 @@ export const runWebDbWorkerPort = (config: { export const runWebDbWorkerPortWithOptions = ( config: { readonly name: SimpleName; + readonly consoleLevel?: ConsoleLevel; readonly port: MessagePort; readonly brokerPort: MessagePort; }, From 26ef9224ae6a0b8c770117eb3f49339d0f1f17b6 Mon Sep 17 00:00:00 2001 From: Miccy Date: Mon, 16 Feb 2026 04:58:55 +0100 Subject: [PATCH 50/60] docs: add common-v8 parity plan outlining compatibility goals and execution strategy. --- .../common-v8-parity-plan-2026-02-16.md | 82 +++++++ .ai/memory/user-preferences.md | 12 + bun.lock | 128 ++++++++++- packages/common/src/Sqlite.ts | 2 +- packages/common/src/Worker.ts | 182 +++++++++++++++ packages/common/src/local-first/Storage.ts | 23 +- packages/common/src/local-first/Worker.ts | 31 ++- packages/common/test/Sqlite.test.ts | 3 +- packages/common/test/Worker.test.ts | 212 ++++++++++++++++++ packages/common/test/_deps.nodejs.ts | 113 +--------- packages/common/test/_deps.ts | 110 +++++++++ .../common/test/local-first/Protocol.test.ts | 2 +- .../common/test/local-first/Relay.test.ts | 2 +- .../common/test/local-first/Schema.test.ts | 2 +- .../common/test/local-first/Storage.test.ts | 2 +- .../common/test/local-first/Worker.test.ts | 49 ++++ packages/nodejs/src/local-first/Relay.ts | 4 +- packages/web/src/local-first/Worker.worker.ts | 9 +- 18 files changed, 825 insertions(+), 143 deletions(-) create mode 100644 .ai/knowledge/common-v8-parity-plan-2026-02-16.md create mode 100644 packages/common/test/Worker.test.ts diff --git a/.ai/knowledge/common-v8-parity-plan-2026-02-16.md b/.ai/knowledge/common-v8-parity-plan-2026-02-16.md new file mode 100644 index 000000000..e654d4d9a --- /dev/null +++ b/.ai/knowledge/common-v8-parity-plan-2026-02-16.md @@ -0,0 +1,82 @@ +# Common-v8 Parity Plan (Bun-first) + +## Goal + +Reach practical `upstream/common-v8` compatibility for architecture/API/behavior while keeping: + +- `bun` runtime and package manager +- `biome` instead of eslint/prettier +- reduced dependencies and Bun-native replacements where beneficial + +## Hard Rules + +- No wholesale merge of upstream branch. +- No return of removed tooling (`pnpm`, `pnpx`, `eslint`, `prettier`). +- Every upstream commit must be classified: + - `verified-equivalent` + - `pending-adopt` + - `intentional-divergence` +- Any divergence must include reason + impact. + +## Verification Gates + +- `bunx tsc --build packages/common packages/web packages/react-native packages/nodejs` +- `bunx vitest run` for targeted suites around touched areas +- `bun run verify:fast` +- `SYNC_GUARD_MAIN_REF=miccy-dev bun run sync:guard:common-v8:strict` + +## Upstream Commit Matrix (Current Snapshot) + +Source: `git cherry -v miccy-dev upstream/common-v8` + +| Upstream | Subject | Status | Local Note | +|---|---|---|---| +| `fc99998` | Add evoluError tab channel and error handling | `verified-equivalent` | Implemented via local worker/tab error flow (`EvoluTabOutput`, `InitTab`, shared `evoluError` store). | +| `c3e32b0` | Update pnpm-lock.yaml | `intentional-divergence` | Bun lockfile is authoritative. | +| `e62d12a` | Add microtask batch utility | `verified-equivalent` | Present in local fork (`microtask batch` utility + export). | +| `31e07eb` | Implement mutation methods | `pending-adopt` | Needs strict parity check for mutation dispatch/coalescing path. | +| `da75834` | Rename worker module to Shared | `intentional-divergence` | Keep `Worker.ts` naming in fork; behavior parity still required. | +| `019ad76` | Add testCreateWorker and worker tests | `verified-equivalent` | Backported: `testCreateWorker`, `testCreateMessageChannel`, `testCreateMessagePort`, `testCreateSharedWorker` + `packages/common/test/Worker.test.ts`. | +| `b90fae5` | Wire createEvolu to DB worker init | `pending-adopt` | Re-check against current `createEvolu` + DB worker init payload flow. | +| `d4fb5ba` | Wire platform DB workers and worker rename | `pending-adopt` | Port behavior without forced file/module rename. | +| `a986d23` | Add leader lock and worker init wiring | `pending-adopt` | Leader logic exists locally; verify contract and edge-cases against upstream. | +| `cc87fde` | Add canonical test name constants | `verified-equivalent` | `testName`/`testAppName` present locally. | +| `857608f` | Rename common test deps module | `verified-equivalent` | Test helper imports now converge on `_deps.ts`; `_deps.nodejs.ts` left as compatibility re-export shim. | +| `d5e7e3c` | Add React Native leader lock tests | `verified-equivalent` | RN leader lock tests are present and green. | +| `27a34ec` | Add web leader lock browser tests | `verified-equivalent` | Web leader lock browser tests are present and green. | +| `cb1e7e0` | Use store-only worker console output | `verified-equivalent` | Worker bootstrap now uses `createWorkerRun` + store output forwarding path. | +| `f05ec75` | Use Run type for run.daemon | `verified-equivalent` | Already ported in local fork. | +| `af7aca4` | Update TreeShaking.test.ts | `pending-adopt` | Compare fixture/snapshot intent, keep Bun-compatible tooling. | +| `2cc6272` | Sort imports | `intentional-divergence` | Non-functional; handled by Biome formatting policy. | +| `2a585dc` | Centralize deterministic test values | `verified-equivalent` | Already ported. | +| `fb46547` | Use deterministic AppOwner in playground | `verified-equivalent` | Already ported. | +| `fefc8e3` | Refactor db worker init flow | `pending-adopt` | Reconcile with Bun-first worker runtime path. | +| `db759d4` | Broker channel wiring + leader acquired output | `pending-adopt` | Compare contract edges and callbacks end-to-end. | +| `a2a448d` | Typed NativeMessagePort generics | `verified-equivalent` | Already ported. | +| `746ea84` | Refactored local-first worker channel wiring | `verified-equivalent` | Already ported with Bun-first file layout. | +| `0b390f6` | Clarified SharedWorker communication comment | `verified-equivalent` | Doc-level parity covered in local comments/docs. | +| `0bbef0a` | Simplified worker deps + forwarded DbWorker console entries | `verified-equivalent` | Ported behavior; continue regression checks. | +| `57d2390` | Wired DbWorker init payload and worker output flow | `verified-equivalent` | Ported with local worker protocol/wiring. | +| `d4f0e5f` | Wired sqlite driver into React Native deps | `verified-equivalent` | Ported. | +| `096326e` | Documented type aliases for composed deps | `intentional-divergence` | Upstream `apps/web` docs were removed from this fork; docs parity is tracked in `website/apps/docs`. | +| `7f12041` | Refactored DB worker startup to initialize schema/bootstrap clock | `pending-adopt` | Needs selective backport into Bun worker path. | +| `f94006d` | SQLite throw-first semantics + protocol/storage alignment | `verified-equivalent` | Ported in local fork (conflict-resolved variant). | +| `1f7be0d` | Update pnpm-lock.yaml | `intentional-divergence` | Bun lockfile only. | +| `af2e3f6` | Use getOk in SQLite setup paths | `verified-equivalent` | Ported. | +| `717abb4` | Upgrade Biome to v2.4.0 and update config | `verified-equivalent` | Ported via local dependency/config update. | +| `c089ae9` | Queue mutations, add quarantine, rename messages | `pending-adopt` | Major functional gap to finish. | + +## Execution Order + +1. Worker/runtime parity batch: + - `a986d23`, `fefc8e3`, `db759d4`, `b90fae5`, `d4fb5ba`, `857608f`, `d5e7e3c`, `27a34ec`, `cb1e7e0` +2. Mutation queue/protocol batch: + - `31e07eb`, `c089ae9` +3. DB startup/docs batch: + - `7f12041`, `019ad76`, `af7aca4`, `096326e` + +## Bun-native Replacement Opportunities (After Parity) + +- Keep `webpack` only where explicitly needed for compatibility snapshots (tree-shaking baseline). +- Add optional Bun-native comparison path (`bun build --minify`) for internal perf tracking, not as baseline replacement. +- Review and remove redundant Node-era tooling wrappers where Bun provides equivalent runtime/build/test behavior. diff --git a/.ai/memory/user-preferences.md b/.ai/memory/user-preferences.md index b8dff9b44..ffa8acac6 100644 --- a/.ai/memory/user-preferences.md +++ b/.ai/memory/user-preferences.md @@ -9,3 +9,15 @@ ## Specifika Projektu - **Aktuální stav**: - **Kritické Todo**: + +## Sync Pravidla (`upstream/common-v8` -> `evolu-plan-b`) +- **Baseline**: `common-v8` je výchozí architektura/UX/API kompatibilita. +- **Bez wholesale merge**: nikdy neprovádět slepé 1:1 mergnutí celé větve, které vrací odstraněné dependency/tooling. +- **Bun-first/Biome-first**: zachovat `bun` + `biome`, nevracet `pnpm`, `pnpx`, `eslint`, `prettier` ani jejich workflow. +- **Commit-parity workflow**: každou upstream změnu mapovat jako `adopt exact` / `equivalent` / `intentional divergence`. +- **Povinný reporting odchylek předem**: pokud nebude něco implementováno nebo bude řešeno jinak, vždy předem uvést důvod, dopad a návrh. +- **Performance & deps policy**: aktivně hledat náhrady 3rd-party knihoven nativními Bun funkcemi a hlásit je; preferovat méně závislostí a vyšší výkon. +- **No chaos refactors**: nepřesouvat naming/soubory bez jasného přínosu (výkon, stabilita, kompatibilita). +- **Keep local supersets**: pokud máme navíc oproti upstreamu (vyřešený issue, lepší implementace, funkce navíc), nikdy to nerevertovat, pokud je zachovaná upstream kompatibilita. +- **Testy můžou být napřed**: aktivně přidávat/rozšiřovat test coverage i nad rámec upstreamu, pokud to zvyšuje jistotu kompatibility a stability. +- **Escalation style**: při nalezení upstream chyby nebo lepší Bun varianty nejdřív zastavit a dodat stručný report: problém -> dopad -> návrh -> riziko. diff --git a/bun.lock b/bun.lock index 9d0dc9d1f..4bc6e2ba9 100644 --- a/bun.lock +++ b/bun.lock @@ -1594,7 +1594,7 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], @@ -1716,7 +1716,7 @@ "bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], - "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], + "bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -2898,7 +2898,7 @@ "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="], - "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], @@ -3362,12 +3362,16 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@angular-devkit/core/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + "@angular-devkit/core/ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "@angular-devkit/core/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "@angular/build/@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="], + "@angular/build/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "@angular/build/vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="], "@angular/build/watchpack": ["watchpack@2.5.0", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-e6vZvY6xboSwLz2GD36c16+O/2Z6fKvIf4pOXptw2rY9MVwE/TXc6RGqxD3I3x0a28lwBY7DE+76uTPSsBrrCA=="], @@ -3538,6 +3542,8 @@ "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], + "app-builder-lib/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "app-builder-lib/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "app-builder-lib/minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="], @@ -3594,6 +3600,8 @@ "dmg-license/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "electron/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], "electron-builder/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], @@ -3634,6 +3642,8 @@ "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], + "jest-haste-map/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "jest-haste-map/jest-worker": ["jest-worker@29.7.0", "", { "dependencies": { "@types/node": "*", "jest-util": "^29.7.0", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw=="], "jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -3738,8 +3748,6 @@ "sharp-ico/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], - "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], - "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], @@ -3776,6 +3784,8 @@ "unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], + "vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "webpack/es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], @@ -3800,10 +3810,14 @@ "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "@angular-devkit/core/ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "@angular/build/@babel/core/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "@angular/build/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@angular/build/vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "@angular/build/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "@angular/compiler-cli/@babel/core/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -4084,6 +4098,58 @@ "test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "workbox-build/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "workbox-build/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], @@ -4092,6 +4158,58 @@ "workbox-build/rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@angular/build/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@angular/build/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@angular/build/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@angular/build/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@angular/build/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@angular/build/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@angular/build/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@angular/build/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@angular/build/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@angular/build/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@angular/build/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@angular/build/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@angular/build/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@angular/build/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@angular/build/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@angular/build/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@angular/build/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@angular/build/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@angular/build/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@angular/build/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@angular/build/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@angular/build/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@angular/build/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@angular/build/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@angular/build/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@angular/build/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "@angular/compiler-cli/yargs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "@angular/compiler-cli/yargs/cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], diff --git a/packages/common/src/Sqlite.ts b/packages/common/src/Sqlite.ts index 52f36013a..b1ac5c738 100644 --- a/packages/common/src/Sqlite.ts +++ b/packages/common/src/Sqlite.ts @@ -198,7 +198,7 @@ export const createSqlite = return result as SqliteExecResult; }, - transaction: ((callback: () => Result | void) => { + transaction: ((callback: () => Result | undefined) => { console.debug("begin"); driver.exec(sql`begin;`); diff --git a/packages/common/src/Worker.ts b/packages/common/src/Worker.ts index c6c657fef..7585bb92e 100644 --- a/packages/common/src/Worker.ts +++ b/packages/common/src/Worker.ts @@ -206,3 +206,185 @@ export interface WorkerScope export interface SharedWorkerScope extends SharedWorkerSelf, GlobalErrorScope {} + +/** + * Creates a connected {@link Worker} / {@link WorkerSelf} pair for testing. + * + * Returns both sides so tests can exercise dedicated worker communication + * without a real worker thread. + */ +export const testCreateWorker = (): { + readonly worker: Worker; + readonly self: WorkerSelf; +} => { + const channel = testCreateMessageChannel(); + + const worker: Worker = { + postMessage: channel.port1.postMessage, + get onMessage() { + return channel.port1.onMessage; + }, + set onMessage(value) { + channel.port1.onMessage = value; + }, + native: channel.port1.native, + [Symbol.dispose]: () => { + channel[Symbol.dispose](); + }, + }; + + const self: WorkerSelf = { + postMessage: channel.port2.postMessage, + get onMessage() { + return channel.port2.onMessage; + }, + set onMessage(value) { + channel.port2.onMessage = value; + }, + native: channel.port2.native, + [Symbol.dispose]: () => { + channel.port2[Symbol.dispose](); + }, + }; + + return { worker, self }; +}; + +/** + * Creates a connected {@link SharedWorker} / {@link SharedWorkerSelf} pair for + * testing. + * + * Returns both sides so tests can exercise the full worker ↔ client pipeline + * without a real worker thread. Calling `connect()` triggers `self.onConnect`. + */ +export const testCreateSharedWorker = (): { + readonly worker: SharedWorker; + readonly self: SharedWorkerSelf; + readonly connect: () => void; +} => { + const channel = testCreateMessageChannel(); + + const self: SharedWorkerSelf = { + onConnect: null, + [Symbol.dispose]: () => { + self.onConnect = null; + }, + }; + + const worker: SharedWorker = { + port: channel.port1, + [Symbol.dispose]: () => { + channel[Symbol.dispose](); + }, + }; + + const connect = () => { + if (self.onConnect) self.onConnect(channel.port2); + }; + + return { worker, self, connect }; +}; + +/** {@link MessageChannel} with disposal tracking for testing. */ +export interface TestMessageChannel + extends MessageChannel { + readonly isDisposed: () => boolean; +} + +/** + * Creates an in-memory {@link MessageChannel} for testing. + * + * Messages are queued until `onMessage` is assigned, matching the browser + * MessagePort behavior where the port message queue starts disabled. + * + * Both ports are registered in the native port registry so + * {@link testCreateMessagePort} can look them up by their native token. + */ +export const testCreateMessageChannel = < + Input, + Output = never, +>(): TestMessageChannel => { + const state1: TestPortState = { handler: null, queue: [] }; + const state2: TestPortState = { handler: null, queue: [] }; + + const native1 = Symbol("NativeMessagePort1") as unknown as NativeMessagePort< + Input, + Output + >; + const native2 = Symbol("NativeMessagePort2") as unknown as NativeMessagePort< + Output, + Input + >; + + const port1 = createTestPort(state1, state2, native1); + const port2 = createTestPort(state2, state1, native2); + + nativePortRegistry.set(native1, port1); + nativePortRegistry.set(native2, port2); + + let disposed = false; + + return { + port1, + port2, + isDisposed: () => disposed, + [Symbol.dispose]: () => { + disposed = true; + port1[Symbol.dispose](); + port2[Symbol.dispose](); + }, + }; +}; + +/** Creates an in-memory {@link CreateMessagePort} for testing. */ +export const testCreateMessagePort: CreateMessagePort = ( + nativePort: NativeMessagePort, +): MessagePort => { + const pair = nativePortRegistry.get(nativePort); + if (!pair) throw new Error("Unknown native port — did you transfer it?"); + return pair as MessagePort; +}; + +interface TestPortState { + handler: ((message: T) => void) | null; + readonly queue: Array; +} + +const createTestPort = ( + receive: TestPortState, + peerReceive: TestPortState, + native: NativeMessagePort, +): MessagePort => ({ + postMessage: (message) => { + if (peerReceive.handler) peerReceive.handler(message); + else peerReceive.queue.push(message); + }, + get onMessage() { + return receive.handler; + }, + set onMessage(fn) { + receive.handler = fn; + if (fn) { + for (const msg of receive.queue.splice(0)) fn(msg); + } + }, + native, + [Symbol.dispose]: () => { + receive.handler = null; + }, +}); + +/** + * Registry mapping native port tokens to their in-memory port counterparts. + * + * When {@link testCreateMessageChannel} creates a pair, both ports are + * registered here. When {@link testCreateMessagePort} wraps a native token + * (received via `postMessage` transfer), it looks up the actual port. + * + * Uses `WeakMap` so entries are garbage collected when the native token is no + * longer referenced. + */ +const nativePortRegistry = new WeakMap< + NativeMessagePort, + MessagePort +>(); diff --git a/packages/common/src/local-first/Storage.ts b/packages/common/src/local-first/Storage.ts index 7a5ab2a20..32650b8b9 100644 --- a/packages/common/src/local-first/Storage.ts +++ b/packages/common/src/local-first/Storage.ts @@ -1483,14 +1483,21 @@ const fingerprintRanges = `); const fingerprintRanges = result.rows.map( - (row, i, arr): FingerprintRange => ({ - type: RangeType.Fingerprint, - upperBound: i === arr.length - 1 ? upperBound : row.b!, - fingerprint: sqliteFingerprintToFingerprint([ - row.h1, - row.h2, - ] as SqliteFingerprint), - }), + (row, i, arr): FingerprintRange => { + const nextUpperBound = i === arr.length - 1 ? upperBound : row.b; + assert( + nextUpperBound != null, + "Missing fingerprint upperBound row value", + ); + return { + type: RangeType.Fingerprint, + upperBound: nextUpperBound, + fingerprint: sqliteFingerprintToFingerprint([ + row.h1, + row.h2, + ] as SqliteFingerprint), + }; + }, ); return fingerprintRanges; diff --git a/packages/common/src/local-first/Worker.ts b/packages/common/src/local-first/Worker.ts index 708465414..fd62c3fbc 100644 --- a/packages/common/src/local-first/Worker.ts +++ b/packages/common/src/local-first/Worker.ts @@ -4,7 +4,12 @@ * @module */ -import type { ConsoleDep, ConsoleEntry, ConsoleLevel } from "../Console.js"; +import type { + ConsoleDep, + ConsoleEntry, + ConsoleLevel, + ConsoleStoreOutputEntryDep, +} from "../Console.js"; import { exhaustiveCheck } from "../Function.js"; import { ok } from "../Result.js"; import type { Task } from "../Task.js"; @@ -67,7 +72,9 @@ export interface RunDbWorkerPortDep { export const runEvoluWorkerScope = (deps: CreateMessagePortDep & RunDbWorkerPortDep & ConsoleDep) => - (self: EvoluWorkerScope): void => { + ( + self: EvoluWorkerScope, + ): { readonly postTabOutput: (output: EvoluTabOutput) => void } => { const tabPorts = new Set>(); const queuedTabOutputs: Array = []; let consoleLevel = deps.console.getLevel(); @@ -123,6 +130,8 @@ export const runEvoluWorkerScope = } }; }; + + return { postTabOutput }; }; /** @@ -136,9 +145,23 @@ export const initEvoluWorker = ): Task< void, never, - CreateMessagePortDep & RunDbWorkerPortDep & ConsoleDep + CreateMessagePortDep & + RunDbWorkerPortDep & + ConsoleDep & + ConsoleStoreOutputEntryDep > => (run) => { - runEvoluWorkerScope(run.deps)(self); + const { postTabOutput } = runEvoluWorkerScope(run.deps)(self); + + const unsubscribe = run.deps.consoleStoreOutputEntry.subscribe(() => { + const entry = run.deps.consoleStoreOutputEntry.get(); + if (entry) postTabOutput({ type: "ConsoleEntry", entry }); + }); + + run.defer(() => { + unsubscribe(); + return ok(); + }); + return ok(); }; diff --git a/packages/common/test/Sqlite.test.ts b/packages/common/test/Sqlite.test.ts index e5bf6dc0f..20518f7d0 100644 --- a/packages/common/test/Sqlite.test.ts +++ b/packages/common/test/Sqlite.test.ts @@ -17,8 +17,7 @@ import { } from "../src/Sqlite.js"; import { sleep } from "../src/Task.js"; import { testCreateRun } from "../src/Test.js"; -import { testSimpleName } from "./_deps.js"; -import { testCreateRunWithSqlite } from "./_deps.nodejs.js"; +import { testCreateRunWithSqlite, testSimpleName } from "./_deps.js"; describe("eqSqliteValue", () => { test("equal Uint8Arrays return true", () => { diff --git a/packages/common/test/Worker.test.ts b/packages/common/test/Worker.test.ts new file mode 100644 index 000000000..76173186c --- /dev/null +++ b/packages/common/test/Worker.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "vitest"; +import { lazyVoid } from "../src/Function.js"; +import type { NativeMessagePort } from "../src/Worker.js"; +import { + testCreateMessageChannel, + testCreateMessagePort, + testCreateSharedWorker, + testCreateWorker, +} from "../src/Worker.js"; + +describe("testCreateMessageChannel", () => { + test("port1 postMessage delivers to port2 onMessage", () => { + const channel = testCreateMessageChannel(); + const received: Array = []; + channel.port2.onMessage = (msg) => received.push(msg); + channel.port1.postMessage("hello"); + expect(received).toEqual(["hello"]); + }); + + test("port2 postMessage delivers to port1 onMessage", () => { + const channel = testCreateMessageChannel(); + const received: Array = []; + channel.port1.onMessage = (msg) => received.push(msg); + channel.port2.postMessage(42); + expect(received).toEqual([42]); + }); + + test("messages are queued until onMessage is assigned", () => { + const channel = testCreateMessageChannel(); + channel.port1.postMessage("a"); + channel.port1.postMessage("b"); + const received: Array = []; + channel.port2.onMessage = (msg) => received.push(msg); + expect(received).toEqual(["a", "b"]); + }); + + test("messages sent after onMessage is assigned are delivered immediately", () => { + const channel = testCreateMessageChannel(); + const received: Array = []; + channel.port2.onMessage = (msg) => received.push(msg); + channel.port1.postMessage("first"); + channel.port1.postMessage("second"); + expect(received).toEqual(["first", "second"]); + }); + + test("setting onMessage to null stops delivery", () => { + const channel = testCreateMessageChannel(); + const received: Array = []; + channel.port2.onMessage = (msg) => received.push(msg); + channel.port1.postMessage("delivered"); + channel.port2.onMessage = null; + channel.port1.postMessage("queued"); + expect(received).toEqual(["delivered"]); + }); + + test("dispose nulls out handlers", () => { + const channel = testCreateMessageChannel(); + channel.port1.onMessage = lazyVoid; + channel.port2.onMessage = lazyVoid; + expect(channel.port1.onMessage).not.toBeNull(); + expect(channel.port2.onMessage).not.toBeNull(); + channel[Symbol.dispose](); + expect(channel.port1.onMessage).toBeNull(); + expect(channel.port2.onMessage).toBeNull(); + }); + + test("each channel creates independent ports", () => { + const channel1 = testCreateMessageChannel(); + const channel2 = testCreateMessageChannel(); + expect(channel1.port1.native).not.toBe(channel2.port1.native); + expect(channel1.port2.native).not.toBe(channel2.port2.native); + }); + + test("bidirectional communication works", () => { + const channel = testCreateMessageChannel(); + const strings: Array = []; + const numbers: Array = []; + + channel.port2.onMessage = (msg) => strings.push(msg); + channel.port1.onMessage = (msg) => numbers.push(msg); + + channel.port1.postMessage("hello"); + channel.port2.postMessage(42); + + expect(strings).toEqual(["hello"]); + expect(numbers).toEqual([42]); + }); +}); + +describe("testCreateMessagePort", () => { + test("looks up port by native token from channel", () => { + const channel = testCreateMessageChannel(); + const port = testCreateMessagePort(channel.port1.native); + expect(port).toBe(channel.port1); + }); + + test("looks up port2 by native token", () => { + const channel = testCreateMessageChannel(); + const port = testCreateMessagePort(channel.port2.native); + expect(port).toBe(channel.port2); + }); + + test("throws for unknown native port", () => { + const unknownNative = Symbol("unknown") as unknown as NativeMessagePort; + expect(() => testCreateMessagePort(unknownNative)).toThrow( + "Unknown native port", + ); + }); +}); + +describe("testCreateWorker", () => { + test("worker and self communicate through ports", () => { + const { worker, self } = testCreateWorker(); + const workerReceived: Array = []; + const selfReceived: Array = []; + + worker.onMessage = (msg) => workerReceived.push(msg); + self.onMessage = (msg) => selfReceived.push(msg); + + worker.postMessage("to-self"); + self.postMessage(123); + + expect(selfReceived).toEqual(["to-self"]); + expect(workerReceived).toEqual([123]); + }); + + test("messages are queued until onMessage is assigned", () => { + const { worker, self } = testCreateWorker(); + worker.postMessage("queued"); + + const received: Array = []; + self.onMessage = (msg) => received.push(msg); + + expect(received).toEqual(["queued"]); + }); + + test("worker dispose clears handlers", () => { + const { worker, self } = testCreateWorker(); + worker.onMessage = lazyVoid; + self.onMessage = lazyVoid; + + worker[Symbol.dispose](); + + expect(worker.onMessage).toBeNull(); + expect(self.onMessage).toBeNull(); + }); +}); + +describe("testCreateSharedWorker", () => { + test("connect triggers onConnect with worker port", () => { + const { self, connect } = testCreateSharedWorker(); + let connected = false; + self.onConnect = () => { + connected = true; + }; + connect(); + expect(connected).toBe(true); + }); + + test("connect does nothing when onConnect is null", () => { + const { connect } = testCreateSharedWorker(); + expect(() => connect()).not.toThrow(); + }); + + test("worker and self communicate through ports", () => { + const { worker, self, connect } = testCreateSharedWorker(); + const workerReceived: Array = []; + const clientReceived: Array = []; + + self.onConnect = (port) => { + port.onMessage = (msg) => workerReceived.push(msg); + port.postMessage(99); + }; + + worker.port.onMessage = (msg) => clientReceived.push(msg); + connect(); + + worker.port.postMessage("hello"); + + expect(workerReceived).toEqual(["hello"]); + expect(clientReceived).toEqual([99]); + }); + + test("messages sent before connect are queued", () => { + const { worker, self, connect } = testCreateSharedWorker(); + worker.port.postMessage("before-connect"); + + const received: Array = []; + self.onConnect = (port) => { + port.onMessage = (msg) => received.push(msg); + }; + connect(); + + expect(received).toEqual(["before-connect"]); + }); + + test("worker dispose disposes channel", () => { + const { worker } = testCreateSharedWorker(); + worker.port.onMessage = lazyVoid; + expect(worker.port.onMessage).not.toBeNull(); + worker[Symbol.dispose](); + expect(worker.port.onMessage).toBeNull(); + }); + + test("self dispose nulls onConnect", () => { + const { self } = testCreateSharedWorker(); + self.onConnect = lazyVoid; + expect(self.onConnect).not.toBeNull(); + self[Symbol.dispose](); + expect(self.onConnect).toBeNull(); + }); +}); diff --git a/packages/common/test/_deps.nodejs.ts b/packages/common/test/_deps.nodejs.ts index a9e9bf209..f4e444237 100644 --- a/packages/common/test/_deps.nodejs.ts +++ b/packages/common/test/_deps.nodejs.ts @@ -1,111 +1,2 @@ -import { timingSafeEqual } from "node:crypto"; -import BetterSQLite, { type Statement } from "better-sqlite3"; -import { assert } from "../src/Assert.js"; -import type { TimingSafeEqual } from "../src/Crypto.js"; -import { lazyTrue, lazyVoid } from "../src/Function.js"; -import { - createRelaySqliteStorage, - createRelayStorageTables, -} from "../src/local-first/Relay.js"; -import { - createBaseSqliteStorageTables, - type StorageConfig, - type StorageDep, -} from "../src/local-first/Storage.js"; -import { ok } from "../src/Result.js"; -import type { - CreateSqliteDriver, - CreateSqliteDriverDep, - SqliteDep, - SqliteDriver, - SqliteRow, -} from "../src/Sqlite.js"; -import { createPreparedStatementsCache, createSqlite } from "../src/Sqlite.js"; -import type { Run } from "../src/Task.js"; -import { type TestDeps, testCreateRun } from "../src/Test.js"; -import { testSimpleName } from "./_deps.js"; - -export const testTimingSafeEqual: TimingSafeEqual = timingSafeEqual; - -export const testCreateRunWithSqlite = async (): Promise< - Run -> => { - const run = testCreateRun({ - createSqliteDriver: testCreateSqliteDriver, - }); - - const sqlite = await run(createSqlite(testSimpleName)); - assert(sqlite.ok, "bug"); - - run.defer(() => { - sqlite.value[Symbol.dispose](); - return ok(); - }); - - return run.addDeps({ sqlite: sqlite.value }); -}; - -/** Creates a test Run with relay storage and SQLite deps. */ -export const testCreateRunWithSqliteAndRelayStorage = async ( - config?: Partial, -): Promise> => { - const run = await testCreateRunWithSqlite(); - - createBaseSqliteStorageTables(run.deps); - createRelayStorageTables(run.deps); - - const storage = createRelaySqliteStorage({ - ...run.deps, - timingSafeEqual: testTimingSafeEqual, - })({ - isOwnerWithinQuota: lazyTrue, - ...config, - }); - - return run.addDeps({ storage }); -}; - -/** In-memory better-sqlite3 driver for tests. */ -const testCreateSqliteDriver: CreateSqliteDriver = (name) => - createBetterSqliteDriver(name, { mode: "memory" }); - -// Duplicated from @evolu/nodejs because @evolu/common cannot depend on it -// (nodejs depends on common — importing back would create a circular dependency). -const createBetterSqliteDriver: CreateSqliteDriver = (name, options) => () => { - const filename = options?.mode === "memory" ? ":memory:" : `${name}.db`; - const db = new BetterSQLite(filename); - let isDisposed = false; - - const cache = createPreparedStatementsCache( - (sql) => db.prepare(sql), - // Not needed. - // https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md#class-statement - lazyVoid, - ); - - const driver: SqliteDriver = { - exec: (query) => { - // Always prepare is recommended for better-sqlite3 - const prepared = cache.get(query, true); - - if (prepared.reader) { - const rows = prepared.all(query.parameters) as Array; - return { rows, changes: 0 }; - } - - const changes = prepared.run(query.parameters).changes; - return { rows: [], changes }; - }, - - export: () => db.serialize(), - - [Symbol.dispose]: () => { - if (isDisposed) return; - isDisposed = true; - cache[Symbol.dispose](); - db.close(); - }, - }; - - return ok(driver); -}; +// Backward-compatible test helper path. Prefer importing from "./_deps.js". +export * from "./_deps.js"; diff --git a/packages/common/test/_deps.ts b/packages/common/test/_deps.ts index 19a76bd20..e9f498c3f 100644 --- a/packages/common/test/_deps.ts +++ b/packages/common/test/_deps.ts @@ -1,3 +1,113 @@ +import { timingSafeEqual } from "node:crypto"; +import BetterSQLite, { type Statement } from "better-sqlite3"; +import { assert } from "../src/Assert.js"; +import type { TimingSafeEqual } from "../src/Crypto.js"; +import { lazyTrue, lazyVoid } from "../src/Function.js"; +import { + createRelaySqliteStorage, + createRelayStorageTables, +} from "../src/local-first/Relay.js"; +import { + createBaseSqliteStorageTables, + type StorageConfig, + type StorageDep, +} from "../src/local-first/Storage.js"; +import { ok } from "../src/Result.js"; +import type { + CreateSqliteDriver, + CreateSqliteDriverDep, + SqliteDep, + SqliteDriver, + SqliteRow, +} from "../src/Sqlite.js"; +import { createPreparedStatementsCache, createSqlite } from "../src/Sqlite.js"; +import type { Run } from "../src/Task.js"; +import { type TestDeps, testCreateRun } from "../src/Test.js"; import { SimpleName } from "../src/Type.js"; export const testSimpleName = /*#__PURE__*/ SimpleName.orThrow("Test"); + +export const testTimingSafeEqual: TimingSafeEqual = timingSafeEqual; + +export const testCreateRunWithSqlite = async (): Promise< + Run +> => { + const run = testCreateRun({ + createSqliteDriver: testCreateSqliteDriver, + }); + + const sqlite = await run(createSqlite(testSimpleName)); + assert(sqlite.ok, "bug"); + + run.defer(() => { + sqlite.value[Symbol.dispose](); + return ok(); + }); + + return run.addDeps({ sqlite: sqlite.value }); +}; + +/** Creates a test Run with relay storage and SQLite deps. */ +export const testCreateRunWithSqliteAndRelayStorage = async ( + config?: Partial, +): Promise> => { + const run = await testCreateRunWithSqlite(); + + createBaseSqliteStorageTables(run.deps); + createRelayStorageTables(run.deps); + + const storage = createRelaySqliteStorage({ + ...run.deps, + timingSafeEqual: testTimingSafeEqual, + })({ + isOwnerWithinQuota: lazyTrue, + ...config, + }); + + return run.addDeps({ storage }); +}; + +/** In-memory better-sqlite3 driver for tests. */ +const testCreateSqliteDriver: CreateSqliteDriver = (name) => + createBetterSqliteDriver(name, { mode: "memory" }); + +// Duplicated from @evolu/nodejs because @evolu/common cannot depend on it +// (nodejs depends on common — importing back would create a circular dependency). +const createBetterSqliteDriver: CreateSqliteDriver = (name, options) => () => { + const filename = options?.mode === "memory" ? ":memory:" : `${name}.db`; + const db = new BetterSQLite(filename); + let isDisposed = false; + + const cache = createPreparedStatementsCache( + (sql) => db.prepare(sql), + // Not needed. + // https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md#class-statement + lazyVoid, + ); + + const driver: SqliteDriver = { + exec: (query) => { + // Always prepare is recommended for better-sqlite3 + const prepared = cache.get(query, true); + + if (prepared.reader) { + const rows = prepared.all(query.parameters) as Array; + return { rows, changes: 0 }; + } + + const changes = prepared.run(query.parameters).changes; + return { rows: [], changes }; + }, + + export: () => db.serialize(), + + [Symbol.dispose]: () => { + if (isDisposed) return; + isDisposed = true; + cache[Symbol.dispose](); + db.close(); + }, + }; + + return ok(driver); +}; diff --git a/packages/common/test/local-first/Protocol.test.ts b/packages/common/test/local-first/Protocol.test.ts index 833095bd5..0d69f431f 100644 --- a/packages/common/test/local-first/Protocol.test.ts +++ b/packages/common/test/local-first/Protocol.test.ts @@ -67,7 +67,7 @@ import { NonNegativeInt, PositiveInt, } from "../../src/Type.js"; -import { testCreateRunWithSqliteAndRelayStorage } from "../_deps.nodejs.js"; +import { testCreateRunWithSqliteAndRelayStorage } from "../_deps.js"; import { maxTimestamp, testAppOwner, diff --git a/packages/common/test/local-first/Relay.test.ts b/packages/common/test/local-first/Relay.test.ts index cbc9015d3..ec677abfc 100644 --- a/packages/common/test/local-first/Relay.test.ts +++ b/packages/common/test/local-first/Relay.test.ts @@ -17,7 +17,7 @@ import type { } from "../../src/local-first/Storage.js"; import { createInitialTimestamp } from "../../src/local-first/Timestamp.js"; import { testCreateDeps } from "../../src/Test.js"; -import { testCreateRunWithSqliteAndRelayStorage } from "../_deps.nodejs.js"; +import { testCreateRunWithSqliteAndRelayStorage } from "../_deps.js"; import { testAppOwner, testAppOwner2, diff --git a/packages/common/test/local-first/Schema.test.ts b/packages/common/test/local-first/Schema.test.ts index 879227679..bba63a1e9 100644 --- a/packages/common/test/local-first/Schema.test.ts +++ b/packages/common/test/local-first/Schema.test.ts @@ -23,7 +23,7 @@ import { NonEmptyString100, nullOr, } from "../../src/Type.js"; -import { testCreateRunWithSqlite } from "../_deps.nodejs.js"; +import { testCreateRunWithSqlite } from "../_deps.js"; const TodoId = id("Todo"); type TodoId = typeof TodoId.Type; diff --git a/packages/common/test/local-first/Storage.test.ts b/packages/common/test/local-first/Storage.test.ts index 6167d77a5..9ff0313bf 100644 --- a/packages/common/test/local-first/Storage.test.ts +++ b/packages/common/test/local-first/Storage.test.ts @@ -30,7 +30,7 @@ import { sql } from "../../src/Sqlite.js"; import { testCreateDeps } from "../../src/Test.js"; import type { Millis } from "../../src/Time.js"; import { createId, NonNegativeInt, type PositiveInt } from "../../src/Type.js"; -import { testCreateRunWithSqlite } from "../_deps.nodejs.js"; +import { testCreateRunWithSqlite } from "../_deps.js"; import { testAnotherTimestampsAsc, testAppOwner2, diff --git a/packages/common/test/local-first/Worker.test.ts b/packages/common/test/local-first/Worker.test.ts index 97a8cff0c..d3d60c2c2 100644 --- a/packages/common/test/local-first/Worker.test.ts +++ b/packages/common/test/local-first/Worker.test.ts @@ -1,6 +1,7 @@ import { expect, test, vi } from "vitest"; import { createConsole } from "../../src/Console.js"; import { createUnknownError } from "../../src/Error.js"; +import type { ConsoleEntry } from "../../src/index.js"; import type { DbWorkerInput, DbWorkerLeaderInput, @@ -10,8 +11,11 @@ import type { import { type EvoluTabOutput, type EvoluWorkerInput, + initEvoluWorker, runEvoluWorkerScope, } from "../../src/local-first/Worker.js"; +import { createStore } from "../../src/Store.js"; +import { createRun } from "../../src/Task.js"; import { SimpleName } from "../../src/Type.js"; import type { CreateMessagePort, @@ -146,3 +150,48 @@ test("runEvoluWorkerScope routes InitEvolu port to db worker runner", () => { brokerPort: brokerPort.port, }); }); + +test("initEvoluWorker forwards console store output to tab port", async () => { + const workerScope = createWorkerScope(); + const workerConnection = createTrackedPort(); + const tabPort = createTrackedPort(); + const entryStore = createStore(null); + + const createMessagePort: CreateMessagePort = ( + nativePort: NativeMessagePort, + ): MessagePort => { + if ( + (nativePort as NativeMessagePort) === + (tabPort.native as NativeMessagePort) + ) + return tabPort.port as unknown as MessagePort; + throw new Error("Unexpected native port"); + }; + + await using run = createRun({ + console: createConsole(), + consoleStoreOutputEntry: entryStore, + createMessagePort, + runDbWorkerPort: vi.fn(), + }); + + const initResult = await run(initEvoluWorker(workerScope)); + expect(initResult.ok).toBe(true); + if (!initResult.ok) return; + + workerScope.onConnect?.(workerConnection.port); + workerConnection.emit({ + type: "InitTab", + consoleLevel: "debug", + port: tabPort.native, + }); + + const entry: ConsoleEntry = { + method: "info", + path: [], + args: ["forwarded"], + }; + entryStore.set(entry); + + expect(tabPort.sentMessages).toContainEqual({ type: "ConsoleEntry", entry }); +}); diff --git a/packages/nodejs/src/local-first/Relay.ts b/packages/nodejs/src/local-first/Relay.ts index 28e9de3f4..946420499 100644 --- a/packages/nodejs/src/local-first/Relay.ts +++ b/packages/nodejs/src/local-first/Relay.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import { createServer } from "node:http"; import { type CreateSqliteDriverDep, callback, @@ -25,8 +27,6 @@ import { type Relay, type RelayConfig, } from "@evolu/common/local-first"; -import { existsSync } from "fs"; -import { createServer } from "http"; import { WebSocket, WebSocketServer } from "ws"; import { createTimingSafeEqual } from "../Crypto.js"; import { createBetterSqliteDriver } from "../Sqlite.js"; diff --git a/packages/web/src/local-first/Worker.worker.ts b/packages/web/src/local-first/Worker.worker.ts index b3ee52e7a..4c63e99f8 100644 --- a/packages/web/src/local-first/Worker.worker.ts +++ b/packages/web/src/local-first/Worker.worker.ts @@ -2,12 +2,9 @@ declare const self: SharedWorkerGlobalScope; import { initEvoluWorker } from "@evolu/common/local-first"; -import { createRun } from "../Task.js"; -import { createMessagePort, createSharedWorkerScope } from "../Worker.js"; +import { createSharedWorkerScope, createWorkerRun } from "../Worker.js"; import { runWebDbWorkerPort } from "./DbWorker.js"; -await using run = createRun({ - createMessagePort, - runDbWorkerPort: runWebDbWorkerPort, -}); +await using baseRun = createWorkerRun(); +const run = baseRun.addDeps({ runDbWorkerPort: runWebDbWorkerPort }); await run(initEvoluWorker(createSharedWorkerScope(self))); From 4c5ec885d5e22fae76e99d59caba8ad1e117fea3 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 01:55:14 +0100 Subject: [PATCH 51/60] Added broker channel wiring and leader acquired output handling (cherry picked from commit db759d457ca5ef3f0777d86a210ef3f0459efc2a) --- packages/common/src/local-first/Db.ts | 88 +++++- packages/common/src/local-first/Shared.ts | 151 ++++++++++ .../common/test/local-first/Shared.test.ts | 277 ++++++++++++++++++ 3 files changed, 509 insertions(+), 7 deletions(-) create mode 100644 packages/common/src/local-first/Shared.ts create mode 100644 packages/common/test/local-first/Shared.test.ts diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index 21d740180..640018b5e 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -3,6 +3,80 @@ * * @module */ + +import type { LeaderLockDep } from "../Platform.js"; +import { ok } from "../Result.js"; +import type { AsyncDisposableStack, Task } from "../Task.js"; +import type { Name } from "../Type.js"; +import type { + NativeMessagePort, + Worker, + WorkerInitDep, + WorkerSelf, +} from "../Worker.js"; + +export interface DbWorkerInput { + readonly type: "init"; + readonly name: Name; + readonly brokerPort: NativeMessagePort; +} + +export type DbWorker = Worker; + +export type CreateDbWorker = () => DbWorker; + +export interface CreateDbWorkerDep { + readonly createDbWorker: CreateDbWorker; +} + +export interface DbWorkerLeaderOutput { + readonly type: "LeaderAcquired"; + readonly name: Name; +} + +export const initDbWorker = + ( + self: WorkerSelf, + ): Task => + (run) => { + const { leaderLock } = run.deps; + const stack = run.stack(); + + let initialized = false; + + self.onMessage = ({ name, brokerPort: nativeBrokerPort }) => { + if (!initialized) { + initialized = true; + + const brokerPort = + run.deps.createMessagePort(nativeBrokerPort); + + void run.daemon(async (run) => { + await stack.use(leaderLock.acquire(name)); + brokerPort.postMessage({ type: "LeaderAcquired", name }); + return run(initializeDb(name)); + }); + } + }; + + return ok(stack); + }; + +const initializeDb = + (name: Name): Task => + (run) => { + const _console = run.deps.console.child("DbWorker"); + + globalThis.console.info("initializeDb", { name }); + // TODO: Add parallel stale-leader detection. + // Heartbeat is emitted by the active DB worker and sent to + // SharedWorker. SharedWorker tracks last-seen heartbeat per Evolu + // name and if silent for 10 seconds, it waits for another DB worker + // to announce itself alive and then routes requests to that worker. + + return ok(); + }; + // import { // firstInArray, // isNonEmptyArray, @@ -27,7 +101,7 @@ // SqliteError, // } from "../Sqlite.js"; // import { TimeDep } from "../Time.js"; -// import { Id, Mnemonic, SimpleName } from "../Type.js"; +// import { Id, Mnemonic, Name } from "../Type.js"; // import { CreateWebSocketDep } from "../WebSocket.js"; // import { // createInitializedWorkerWithHandlers, @@ -93,10 +167,10 @@ // * ### Example // * // * ```ts -// * // name: SimpleName.orThrow("MyApp") +// * // name: Name.orThrow("MyApp") // * ``` // */ -// readonly name: SimpleName; +// readonly name: Name; // /** // * Transport configuration for data sync and backup. Supports single transport @@ -180,13 +254,13 @@ // * // * // Local-only instance for device settings (no sync) // * const deviceEvolu = createEvolu(evoluReactWebDeps)(DeviceSchema, { -// * name: SimpleName.orThrow("MyApp-Device"), +// * name: Name.orThrow("MyApp-Device"), // * transports: [], // No sync - stays local to device // * }); // * // * // Main synced instance for user data // * const evolu = createEvolu(evoluReactWebDeps)(MainSchema, { -// * name: SimpleName.orThrow("MyApp"), +// * name: Name.orThrow("MyApp"), // * // Default transports for sync // * }); // * ``` @@ -215,7 +289,7 @@ // } // export const defaultDbConfig: DbConfig = { -// name: SimpleName.orThrow("Evolu"), +// name: Name.orThrow("Evolu"), // transports: [{ type: "WebSocket", url: "wss://free.evoluhq.com" }], // maxDrift: 5 * 60 * 1000, // enableLogging: false, @@ -223,7 +297,7 @@ // export type DbWorker = Worker; -// export type CreateDbWorker = (name: SimpleName) => DbWorker; +// export type CreateDbWorker = (name: Name) => DbWorker; // export interface CreateDbWorkerDep { // readonly createDbWorker: CreateDbWorker; diff --git a/packages/common/src/local-first/Shared.ts b/packages/common/src/local-first/Shared.ts new file mode 100644 index 000000000..6e110c71f --- /dev/null +++ b/packages/common/src/local-first/Shared.ts @@ -0,0 +1,151 @@ +/** + * Platform-agnostic Evolu Worker. + * + * @module + */ + +import type { NonEmptyReadonlyArray } from "../Array.js"; +import type { CallbackId } from "../Callbacks.js"; +import type { ConsoleEntry } from "../Console.js"; +import { exhaustiveCheck } from "../Function.js"; +import { ok } from "../Result.js"; +import type { Task } from "../Task.js"; +import type { Name } from "../Type.js"; +import type { + SharedWorker as CommonSharedWorker, + MessagePort, + NativeMessagePort, + SharedWorkerSelf, + WorkerInitDep, +} from "../Worker.js"; +import type { DbWorkerLeaderOutput } from "./Db.js"; +import type { EvoluError } from "./Error.js"; +import type { Query } from "./Query.js"; +import type { MutationChange } from "./Schema.js"; + +export type SharedWorker = CommonSharedWorker; + +export interface SharedWorkerDep { + readonly sharedWorker: SharedWorker; +} + +/** + * Messages sent from an Evolu instance to the worker-side Evolu port. + * + * Redesign status: currently only mutation dispatch is defined. Additional + * request variants will be added as query and owner flows are implemented. + */ +export interface EvoluInput { + readonly type: "mutate"; + readonly changes: NonEmptyReadonlyArray; + readonly onCompleteIds: ReadonlyArray; + readonly subscribedQueries: ReadonlyArray; +} + +export type SharedWorkerInput = + | { + /** Tab-level channel for broadcast outputs (console/error). */ + readonly type: "InitTab"; + readonly port: NativeMessagePort; + } + | { + /** Per-Evolu instance request channel. */ + readonly type: "InitEvolu"; + readonly name: Name; + readonly port: NativeMessagePort; + readonly brokerPort: NativeMessagePort; + }; + +export type EvoluTabOutput = + | { + readonly type: "ConsoleEntry"; + readonly entry: ConsoleEntry; + } + | { + readonly type: "EvoluError"; + readonly error: EvoluError; + }; + +export const initSharedWorker = + ( + self: SharedWorkerSelf, + ): Task => + async (run) => { + const { createMessagePort, consoleStoreOutputEntry } = run.deps; + const console = run.deps.console.child("SharedWorker"); + + // TODO: Use heartbeat to detect and prune dead ports. + const tabPorts = new Set>(); + const queuedTabOutputs: Array = []; + const leaderPorts = new Map< + Name, + MessagePort + >(); + + const postTabOutput = (output: EvoluTabOutput): void => { + for (const port of tabPorts) port.postMessage(output); + }; + + const postOrQueueTabOutput = (output: EvoluTabOutput): void => { + if (tabPorts.size === 0) queuedTabOutputs.push(output); + else postTabOutput(output); + }; + + await using stack = run.stack(); + + stack.defer( + consoleStoreOutputEntry.subscribe(() => { + const entry = consoleStoreOutputEntry.get(); + if (entry) postOrQueueTabOutput({ type: "ConsoleEntry", entry }); + }), + ); + + console.info("initSharedWorker"); + + self.onConnect = (port) => { + console.info("onConnect"); + + port.onMessage = (message) => { + switch (message.type) { + case "InitTab": { + const tabPort = createMessagePort(message.port); + tabPorts.add(tabPort); + + if (queuedTabOutputs.length > 0) { + queuedTabOutputs.forEach(postTabOutput); + queuedTabOutputs.length = 0; + } + + break; + } + case "InitEvolu": { + // TODO: Wrap port, do async init (open DB, load owner), + // then set onMessage to start processing requests. + // Messages are queued until onMessage is assigned. + const evoluPort = createMessagePort( + message.port, + ); + const brokerPort = createMessagePort( + message.brokerPort, + ); + + leaderPorts.set(message.name, brokerPort); + + brokerPort.onMessage = (leaderEvent) => { + leaderPorts.set(leaderEvent.name, brokerPort); + console.info("leaderAcquired", { name: leaderEvent.name }); + }; + + evoluPort.onMessage = (message) => { + console.log(message); + }; + break; + } + default: + exhaustiveCheck(message); + } + }; + }; + + return ok(stack.move()); + }; diff --git a/packages/common/test/local-first/Shared.test.ts b/packages/common/test/local-first/Shared.test.ts new file mode 100644 index 000000000..2017ff579 --- /dev/null +++ b/packages/common/test/local-first/Shared.test.ts @@ -0,0 +1,277 @@ +import { createStore } from "../../src/Store.js"; +import type { ConsoleEntry } from "../../src/Console.js"; +import type { ReadonlyStore } from "../../src/Store.js"; +import { + type EvoluTabOutput, + type SharedWorkerInput, + initSharedWorker, +} from "../../src/local-first/Shared.js"; +import { testCreateConsole } from "../../src/Console.js"; +import { testCreateRun } from "../../src/Test.js"; +import { + testCreateMessageChannel, + testCreateMessagePort, + testCreateSharedWorker, +} from "../../src/Worker.js"; +import { testName } from "../../src/Type.js"; +import { describe, expect, test } from "vitest"; + +describe("initSharedWorker", () => { + const setupWorker = async ( + consoleStoreOutputEntry: ReadonlyStore = createStore( + null, + ), + ) => { + const { worker, self, connect } = + testCreateSharedWorker(); + + await using run = testCreateRun({ + console: testCreateConsole(), + consoleStoreOutputEntry, + createMessagePort: testCreateMessagePort, + }); + + const initResult = await run(initSharedWorker(self)); + if (!initResult.ok) + throw new Error("initSharedWorker should always succeed"); + + connect(); + + return { + worker, + workerStack: initResult.value, + }; + }; + + test("replays queued console entries after first console port connects", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const firstEntry: ConsoleEntry = { + method: "info", + path: ["before"], + args: ["queued"], + }; + + consoleStoreOutputEntry.set(firstEntry); + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + port: consoleChannel.port1.native, + }); + + const secondEntry: ConsoleEntry = { + method: "info", + path: ["after"], + args: ["live"], + }; + + consoleStoreOutputEntry.set(secondEntry); + + expect(receivedOutputs).toEqual([ + { type: "ConsoleEntry", entry: firstEntry }, + { type: "ConsoleEntry", entry: secondEntry }, + ]); + }); + + test("forwards entries immediately when console port is already connected", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + port: consoleChannel.port1.native, + }); + + const liveEntry: ConsoleEntry = { + method: "info", + path: ["live"], + args: ["entry"], + }; + + consoleStoreOutputEntry.set(liveEntry); + + expect(receivedOutputs).toEqual([ + { type: "ConsoleEntry", entry: liveEntry }, + ]); + }); + + test("ignores null console store updates", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + port: consoleChannel.port1.native, + }); + + const entry: ConsoleEntry = { + method: "info", + path: ["before-null"], + args: ["value"], + }; + + consoleStoreOutputEntry.set(entry); + consoleStoreOutputEntry.set(null); + + expect(receivedOutputs).toEqual([{ type: "ConsoleEntry", entry }]); + }); + + test("forwards typed console error entries as ConsoleEntry", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + port: consoleChannel.port1.native, + }); + + const error = { type: "UnknownError", error: "boom" } as const; + const entry: ConsoleEntry = { + method: "error", + path: ["global"], + args: ["error", error], + }; + + consoleStoreOutputEntry.set(entry); + + expect(receivedOutputs).toEqual([{ type: "ConsoleEntry", entry }]); + }); + + test("forwards untyped console error entries as ConsoleEntry", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + port: consoleChannel.port1.native, + }); + + const entry: ConsoleEntry = { + method: "error", + path: ["global"], + args: ["error", "plain string"], + }; + + consoleStoreOutputEntry.set(entry); + + expect(receivedOutputs).toEqual([{ type: "ConsoleEntry", entry }]); + }); + + test("forwards console error entry with one argument", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + port: consoleChannel.port1.native, + }); + + const entry: ConsoleEntry = { + method: "error", + path: ["global"], + args: ["plain string"], + }; + + consoleStoreOutputEntry.set(entry); + + expect(receivedOutputs).toEqual([{ type: "ConsoleEntry", entry }]); + }); + + test("forwards console error entry with no arguments", async () => { + const consoleStoreOutputEntry = createStore(null); + const { worker, workerStack } = await setupWorker(consoleStoreOutputEntry); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const consoleChannel = testCreateMessageChannel(); + consoleChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + port: consoleChannel.port1.native, + }); + + const entry: ConsoleEntry = { + method: "error", + path: ["global"], + args: [], + }; + + consoleStoreOutputEntry.set(entry); + + expect(receivedOutputs).toEqual([{ type: "ConsoleEntry", entry }]); + }); + + test("accepts InitEvolu message", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + const evoluChannel = testCreateMessageChannel(); + const brokerChannel = testCreateMessageChannel(); + + expect(() => { + worker.port.postMessage({ + type: "InitEvolu", + name: testName, + port: evoluChannel.port1.native, + brokerPort: brokerChannel.port1.native, + }); + }).not.toThrow(); + }); + + test("throws for unknown message type", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + expect(() => { + worker.port.postMessage({ + type: "Unknown", + } as never); + }).toThrow(); + }); +}); From 6e14edfa589a9f6ecfa1e3918f185c1028b59d64 Mon Sep 17 00:00:00 2001 From: Miccy Date: Mon, 16 Feb 2026 06:12:59 +0100 Subject: [PATCH 52/60] chore(sync): backport shared worker wiring and stabilize mutation queue --- .../common-v8-parity-plan-2026-02-16.md | 4 +- packages/common/src/local-first/Db.ts | 14 +-- packages/common/src/local-first/Evolu.ts | 54 +++++---- packages/common/src/local-first/Shared.ts | 35 +++--- packages/common/src/local-first/index.ts | 11 +- .../common/test/local-first/Evolu.test.ts | 111 +++++++++++++----- .../common/test/local-first/Shared.test.ts | 22 ++-- 7 files changed, 162 insertions(+), 89 deletions(-) diff --git a/.ai/knowledge/common-v8-parity-plan-2026-02-16.md b/.ai/knowledge/common-v8-parity-plan-2026-02-16.md index e654d4d9a..500ff0c00 100644 --- a/.ai/knowledge/common-v8-parity-plan-2026-02-16.md +++ b/.ai/knowledge/common-v8-parity-plan-2026-02-16.md @@ -34,8 +34,8 @@ Source: `git cherry -v miccy-dev upstream/common-v8` | `fc99998` | Add evoluError tab channel and error handling | `verified-equivalent` | Implemented via local worker/tab error flow (`EvoluTabOutput`, `InitTab`, shared `evoluError` store). | | `c3e32b0` | Update pnpm-lock.yaml | `intentional-divergence` | Bun lockfile is authoritative. | | `e62d12a` | Add microtask batch utility | `verified-equivalent` | Present in local fork (`microtask batch` utility + export). | -| `31e07eb` | Implement mutation methods | `pending-adopt` | Needs strict parity check for mutation dispatch/coalescing path. | -| `da75834` | Rename worker module to Shared | `intentional-divergence` | Keep `Worker.ts` naming in fork; behavior parity still required. | +| `31e07eb` | Implement mutation methods | `verified-equivalent` | `createEvolu` mutation dispatch/coalescing is implemented and covered; queue processing is serialized across microtasks. | +| `da75834` | Rename worker module to Shared | `verified-equivalent` | `Shared.ts` is now present and exported; legacy `Worker.ts` stays as compatibility layer. | | `019ad76` | Add testCreateWorker and worker tests | `verified-equivalent` | Backported: `testCreateWorker`, `testCreateMessageChannel`, `testCreateMessagePort`, `testCreateSharedWorker` + `packages/common/test/Worker.test.ts`. | | `b90fae5` | Wire createEvolu to DB worker init | `pending-adopt` | Re-check against current `createEvolu` + DB worker init payload flow. | | `d4fb5ba` | Wire platform DB workers and worker rename | `pending-adopt` | Port behavior without forced file/module rename. | diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index 640018b5e..245083064 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -7,18 +7,18 @@ import type { LeaderLockDep } from "../Platform.js"; import { ok } from "../Result.js"; import type { AsyncDisposableStack, Task } from "../Task.js"; -import type { Name } from "../Type.js"; +import type { SimpleName } from "../Type.js"; import type { NativeMessagePort, Worker, - WorkerInitDep, + WorkerDeps, WorkerSelf, } from "../Worker.js"; export interface DbWorkerInput { readonly type: "init"; - readonly name: Name; - readonly brokerPort: NativeMessagePort; + readonly name: SimpleName; + readonly brokerPort: NativeMessagePort; } export type DbWorker = Worker; @@ -31,13 +31,13 @@ export interface CreateDbWorkerDep { export interface DbWorkerLeaderOutput { readonly type: "LeaderAcquired"; - readonly name: Name; + readonly name: SimpleName; } export const initDbWorker = ( self: WorkerSelf, - ): Task => + ): Task => (run) => { const { leaderLock } = run.deps; const stack = run.stack(); @@ -63,7 +63,7 @@ export const initDbWorker = }; const initializeDb = - (name: Name): Task => + (name: SimpleName): Task => (run) => { const _console = run.deps.console.child("DbWorker"); diff --git a/packages/common/src/local-first/Evolu.ts b/packages/common/src/local-first/Evolu.ts index 2f498cd75..c40d3f9a9 100644 --- a/packages/common/src/local-first/Evolu.ts +++ b/packages/common/src/local-first/Evolu.ts @@ -829,6 +829,7 @@ export const createEvolu = const mutateMicrotaskQueue: Array< [MutationChange, MutationOptions["onComplete"] | undefined] > = []; + let isProcessingMutationQueue = false; const createMutation = (kind: Kind): Mutation => @@ -907,36 +908,45 @@ export const createEvolu = }; const processMutationQueue = () => { - const changes: Array = []; - const onCompletes: Array> = []; - - for (const [change, onComplete] of mutateMicrotaskQueue) { - changes.push(change); - if (onComplete) onCompletes.push(onComplete); - } - - mutateMicrotaskQueue.length = 0; - - if (!isNonEmptyArray(changes)) return; + if (isProcessingMutationQueue) return; + isProcessingMutationQueue = true; void (async () => { try { - await dbReady; - const defaultOwnerId = (await getAppOwnerPromise()).id; - - for (const change of changes) { - const ownerId = change.ownerId ?? defaultOwnerId; - const statements = mutationChangeToStatements(change, ownerId); - for (const statement of statements) { - await dbWorker.mutate(statement.sql, statement.params); + while (mutateMicrotaskQueue.length > 0) { + const changes: Array = []; + const onCompletes: Array< + NonNullable + > = []; + + for (const [change, onComplete] of mutateMicrotaskQueue) { + changes.push(change); + if (onComplete) onCompletes.push(onComplete); } - } - await refreshLoadedQueries(); + mutateMicrotaskQueue.length = 0; + if (!isNonEmptyArray(changes)) continue; - for (const onComplete of onCompletes) onComplete(); + await dbReady; + const defaultOwnerId = (await getAppOwnerPromise()).id; + + for (const change of changes) { + const ownerId = change.ownerId ?? defaultOwnerId; + const statements = mutationChangeToStatements(change, ownerId); + for (const statement of statements) { + await dbWorker.mutate(statement.sql, statement.params); + } + } + + await refreshLoadedQueries(); + for (const onComplete of onCompletes) onComplete(); + } } catch (error) { setUnknownError(error); + } finally { + isProcessingMutationQueue = false; + if (mutateMicrotaskQueue.length > 0) + queueMicrotask(processMutationQueue); } })(); }; diff --git a/packages/common/src/local-first/Shared.ts b/packages/common/src/local-first/Shared.ts index 6e110c71f..f4d7bf639 100644 --- a/packages/common/src/local-first/Shared.ts +++ b/packages/common/src/local-first/Shared.ts @@ -10,13 +10,13 @@ import type { ConsoleEntry } from "../Console.js"; import { exhaustiveCheck } from "../Function.js"; import { ok } from "../Result.js"; import type { Task } from "../Task.js"; -import type { Name } from "../Type.js"; +import type { SimpleName } from "../Type.js"; import type { SharedWorker as CommonSharedWorker, MessagePort, NativeMessagePort, SharedWorkerSelf, - WorkerInitDep, + WorkerDeps, } from "../Worker.js"; import type { DbWorkerLeaderOutput } from "./Db.js"; import type { EvoluError } from "./Error.js"; @@ -46,14 +46,14 @@ export type SharedWorkerInput = | { /** Tab-level channel for broadcast outputs (console/error). */ readonly type: "InitTab"; - readonly port: NativeMessagePort; + readonly port: NativeMessagePort; } | { /** Per-Evolu instance request channel. */ readonly type: "InitEvolu"; - readonly name: Name; - readonly port: NativeMessagePort; - readonly brokerPort: NativeMessagePort; + readonly name: SimpleName; + readonly port: NativeMessagePort; + readonly brokerPort: NativeMessagePort; }; export type EvoluTabOutput = @@ -69,7 +69,7 @@ export type EvoluTabOutput = export const initSharedWorker = ( self: SharedWorkerSelf, - ): Task => + ): Task => async (run) => { const { createMessagePort, consoleStoreOutputEntry } = run.deps; const console = run.deps.console.child("SharedWorker"); @@ -78,7 +78,7 @@ export const initSharedWorker = const tabPorts = new Set>(); const queuedTabOutputs: Array = []; const leaderPorts = new Map< - Name, + SimpleName, MessagePort >(); @@ -93,12 +93,15 @@ export const initSharedWorker = await using stack = run.stack(); - stack.defer( - consoleStoreOutputEntry.subscribe(() => { - const entry = consoleStoreOutputEntry.get(); - if (entry) postOrQueueTabOutput({ type: "ConsoleEntry", entry }); - }), - ); + const unsubscribeConsoleStore = consoleStoreOutputEntry.subscribe(() => { + const entry = consoleStoreOutputEntry.get(); + if (entry) postOrQueueTabOutput({ type: "ConsoleEntry", entry }); + }); + + stack.defer(() => { + unsubscribeConsoleStore(); + return ok(); + }); console.info("initSharedWorker"); @@ -131,12 +134,12 @@ export const initSharedWorker = leaderPorts.set(message.name, brokerPort); - brokerPort.onMessage = (leaderEvent) => { + brokerPort.onMessage = (leaderEvent: DbWorkerLeaderOutput) => { leaderPorts.set(leaderEvent.name, brokerPort); console.info("leaderAcquired", { name: leaderEvent.name }); }; - evoluPort.onMessage = (message) => { + evoluPort.onMessage = (message: EvoluInput) => { console.log(message); }; break; diff --git a/packages/common/src/local-first/index.ts b/packages/common/src/local-first/index.ts index dc7eb0ebf..623cac209 100644 --- a/packages/common/src/local-first/index.ts +++ b/packages/common/src/local-first/index.ts @@ -1,4 +1,3 @@ -export * from "./Db.js"; export * from "./DbWorkerProtocol.js"; export * from "./Error.js"; export * from "./Evolu.js"; @@ -7,7 +6,15 @@ export * from "./Protocol.js"; export * from "./Query.js"; export * from "./Relay.js"; export * from "./Schema.js"; +export * from "./Shared.js"; export * from "./Storage.js"; export * from "./Sync.js"; export * from "./Timestamp.js"; -export * from "./Worker.js"; +export { + type EvoluWorker, + type EvoluWorkerDep, + type EvoluWorkerInput, + initEvoluWorker, + type RunDbWorkerPortDep, + runEvoluWorkerScope, +} from "./Worker.js"; diff --git a/packages/common/test/local-first/Evolu.test.ts b/packages/common/test/local-first/Evolu.test.ts index 20e05c90b..203b2e6ff 100644 --- a/packages/common/test/local-first/Evolu.test.ts +++ b/packages/common/test/local-first/Evolu.test.ts @@ -96,13 +96,17 @@ const createMockMessageChannel = () => { }; }; -const createMockDeps = () => { +const createMockDeps = (options?: { + readonly mutateResponseDelayMs?: number; +}) => { const rows = new Map(); let persistedAppOwner: AppOwner | null = null; let reloadCount = 0; let resetCount = 0; let mutateCount = 0; let closeCount = 0; + let inFlightMutates = 0; + let maxInFlightMutates = 0; let tabPort: MockPort | null = null; const brokerInputs: Array = []; @@ -169,41 +173,59 @@ const createMockDeps = () => { } case "DbWorkerMutate": { mutateCount += 1; + inFlightMutates += 1; + maxInFlightMutates = Math.max( + maxInFlightMutates, + inFlightMutates, + ); + + const respond = () => { + if ( + dbMessage.sql?.includes("__evolu_meta") && + dbMessage.params?.length === 1 + ) { + const appOwnerFromDb = dbMessage.params[0]; + if (typeof appOwnerFromDb === "string") { + persistedAppOwner = JSON.parse( + appOwnerFromDb, + ) as AppOwner; + } + } - if ( - dbMessage.sql?.includes("__evolu_meta") && - dbMessage.params?.length === 1 - ) { - const appOwnerFromDb = dbMessage.params[0]; - if (typeof appOwnerFromDb === "string") { - persistedAppOwner = JSON.parse(appOwnerFromDb) as AppOwner; + if ( + dbMessage.sql?.includes(`"title"`) && + dbMessage.params?.length === 3 + ) { + const [, id, title] = dbMessage.params; + rows.set(String(id), { + id: String(id), + title: String(title), + }); } - } - if ( - dbMessage.sql?.includes(`"title"`) && - dbMessage.params?.length === 3 - ) { - const [, id, title] = dbMessage.params; - rows.set(String(id), { - id: String(id), - title: String(title), - }); - } + if ( + dbMessage.sql?.startsWith("delete from") && + dbMessage.params?.length === 2 + ) { + const [, id] = dbMessage.params; + rows.delete(String(id)); + } - if ( - dbMessage.sql?.startsWith("delete from") && - dbMessage.params?.length === 2 - ) { - const [, id] = dbMessage.params; - rows.delete(String(id)); + dbPort.postMessage({ + type: "DbWorkerMutateResponse", + requestId: dbMessage.requestId, + changes: 1, + }); + inFlightMutates -= 1; + }; + + const mutateResponseDelayMs = + options?.mutateResponseDelayMs ?? 0; + if (mutateResponseDelayMs > 0) { + globalThis.setTimeout(respond, mutateResponseDelayMs); + } else { + respond(); } - - dbPort.postMessage({ - type: "DbWorkerMutateResponse", - requestId: dbMessage.requestId, - changes: 1, - }); break; } case "DbWorkerExport": { @@ -253,6 +275,7 @@ const createMockDeps = () => { resetCount, mutateCount, closeCount, + maxInFlightMutates, hasTabPort: tabPort !== null, brokerInputs: [...brokerInputs], rows: [...rows], @@ -464,6 +487,32 @@ describe("createEvolu", () => { const state = getState(); expect(state.closeCount).toBeGreaterThanOrEqual(2); }); + + test("mutation queue stays serialized across microtasks", async () => { + const { deps, getState } = createMockDeps({ mutateResponseDelayMs: 10 }); + const evoluDeps = createEvoluDeps(deps as any); + const evolu = createEvolu(evoluDeps)(Schema, { name: testSimpleName }); + const createQuery = createQueryBuilder(Schema); + const allTodos = createQuery((db) => + db.selectFrom("todo").select(["id", "title"]), + ); + + await evolu.appOwner; + + evolu.insert("todo", { title: NonEmptyString100.orThrow("A") }); + await Promise.resolve(); + evolu.insert("todo", { title: NonEmptyString100.orThrow("B") }); + + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 200); + }); + + const rows = await evolu.loadQuery(allTodos); + const state = getState(); + + expect(rows).toHaveLength(2); + expect(state.maxInFlightMutates).toBe(1); + }); }); // import { describe, expectTypeOf, test } from "vitest"; diff --git a/packages/common/test/local-first/Shared.test.ts b/packages/common/test/local-first/Shared.test.ts index 2017ff579..810d9ef90 100644 --- a/packages/common/test/local-first/Shared.test.ts +++ b/packages/common/test/local-first/Shared.test.ts @@ -1,20 +1,21 @@ -import { createStore } from "../../src/Store.js"; +import { describe, expect, test } from "vitest"; import type { ConsoleEntry } from "../../src/Console.js"; -import type { ReadonlyStore } from "../../src/Store.js"; +import { testCreateConsole } from "../../src/Console.js"; +import type { DbWorkerLeaderOutput } from "../../src/local-first/Db.js"; import { + type EvoluInput, type EvoluTabOutput, - type SharedWorkerInput, initSharedWorker, + type SharedWorkerInput, } from "../../src/local-first/Shared.js"; -import { testCreateConsole } from "../../src/Console.js"; -import { testCreateRun } from "../../src/Test.js"; +import type { ReadonlyStore } from "../../src/Store.js"; +import { createStore } from "../../src/Store.js"; +import { testCreateRun, testName } from "../../src/Test.js"; import { testCreateMessageChannel, testCreateMessagePort, testCreateSharedWorker, } from "../../src/Worker.js"; -import { testName } from "../../src/Type.js"; -import { describe, expect, test } from "vitest"; describe("initSharedWorker", () => { const setupWorker = async ( @@ -251,8 +252,11 @@ describe("initSharedWorker", () => { const { worker, workerStack } = await setupWorker(); await using _workerStack = workerStack; - const evoluChannel = testCreateMessageChannel(); - const brokerChannel = testCreateMessageChannel(); + const evoluChannel = testCreateMessageChannel(); + const brokerChannel = testCreateMessageChannel< + never, + DbWorkerLeaderOutput + >(); expect(() => { worker.port.postMessage({ From e07ee5e61ae06e2d8c919b8c73626af135ae7242 Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 11:55:08 +0100 Subject: [PATCH 53/60] Simplified worker deps and forwarded DbWorker console entries Removed WorkerInitDep in favor of a single WorkerDeps contract, wired DbWorker console store output through the leader channel, and updated SharedWorker handling/tests to forward DbWorker console entries to tab outputs. (cherry picked from commit 0bbef0af912637f725a0d743a2d780fdd0024276) --- packages/common/src/local-first/Db.ts | 42 +++++-- packages/common/src/local-first/Shared.ts | 18 ++- .../common/test/local-first/Shared.test.ts | 114 ++++++++++++++++++ 3 files changed, 160 insertions(+), 14 deletions(-) diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index 245083064..db6f118ff 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -14,6 +14,7 @@ import type { WorkerDeps, WorkerSelf, } from "../Worker.js"; +import type { ConsoleEntry } from "../Console.js"; export interface DbWorkerInput { readonly type: "init"; @@ -29,17 +30,22 @@ export interface CreateDbWorkerDep { readonly createDbWorker: CreateDbWorker; } -export interface DbWorkerLeaderOutput { - readonly type: "LeaderAcquired"; - readonly name: SimpleName; -} +export type DbWorkerLeaderOutput = + | { + readonly type: "LeaderAcquired"; + readonly name: SimpleName; + } + | { + readonly type: "ConsoleEntry"; + readonly entry: ConsoleEntry; + }; export const initDbWorker = ( self: WorkerSelf, ): Task => (run) => { - const { leaderLock } = run.deps; + const { leaderLock, createMessagePort, consoleStoreOutputEntry } = run.deps; const stack = run.stack(); let initialized = false; @@ -48,13 +54,24 @@ export const initDbWorker = if (!initialized) { initialized = true; - const brokerPort = - run.deps.createMessagePort(nativeBrokerPort); + const leaderPort = stack.use( + createMessagePort(nativeBrokerPort), + ); + + const unsubscribeConsoleStore = consoleStoreOutputEntry.subscribe(() => { + const entry = consoleStoreOutputEntry.get(); + if (entry) leaderPort.postMessage({ type: "ConsoleEntry", entry }); + }); + + stack.defer(() => { + unsubscribeConsoleStore(); + return ok(); + }); void run.daemon(async (run) => { await stack.use(leaderLock.acquire(name)); - brokerPort.postMessage({ type: "LeaderAcquired", name }); - return run(initializeDb(name)); + leaderPort.postMessage({ type: "LeaderAcquired", name }); + return run(startDbWorker(name)); }); } }; @@ -62,12 +79,13 @@ export const initDbWorker = return ok(stack); }; -const initializeDb = +const startDbWorker = (name: SimpleName): Task => (run) => { - const _console = run.deps.console.child("DbWorker"); + const console = run.deps.console.child(name).child("DbWorker"); + + console.info("initializeDb", { name }); - globalThis.console.info("initializeDb", { name }); // TODO: Add parallel stale-leader detection. // Heartbeat is emitted by the active DB worker and sent to // SharedWorker. SharedWorker tracks last-seen heartbeat per Evolu diff --git a/packages/common/src/local-first/Shared.ts b/packages/common/src/local-first/Shared.ts index f4d7bf639..6f4244a31 100644 --- a/packages/common/src/local-first/Shared.ts +++ b/packages/common/src/local-first/Shared.ts @@ -135,8 +135,22 @@ export const initSharedWorker = leaderPorts.set(message.name, brokerPort); brokerPort.onMessage = (leaderEvent: DbWorkerLeaderOutput) => { - leaderPorts.set(leaderEvent.name, brokerPort); - console.info("leaderAcquired", { name: leaderEvent.name }); + switch (leaderEvent.type) { + case "LeaderAcquired": { + leaderPorts.set(leaderEvent.name, brokerPort); + console.info("leaderAcquired", { name: leaderEvent.name }); + break; + } + case "ConsoleEntry": { + postOrQueueTabOutput({ + type: "ConsoleEntry", + entry: leaderEvent.entry, + }); + break; + } + default: + exhaustiveCheck(leaderEvent); + } }; evoluPort.onMessage = (message: EvoluInput) => { diff --git a/packages/common/test/local-first/Shared.test.ts b/packages/common/test/local-first/Shared.test.ts index 810d9ef90..69bb22bb3 100644 --- a/packages/common/test/local-first/Shared.test.ts +++ b/packages/common/test/local-first/Shared.test.ts @@ -8,6 +8,7 @@ import { initSharedWorker, type SharedWorkerInput, } from "../../src/local-first/Shared.js"; +import type { MutationChange } from "../../src/local-first/Schema.js"; import type { ReadonlyStore } from "../../src/Store.js"; import { createStore } from "../../src/Store.js"; import { testCreateRun, testName } from "../../src/Test.js"; @@ -268,6 +269,119 @@ describe("initSharedWorker", () => { }).not.toThrow(); }); + test("forwards DbWorker console entries from leader channel", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + const receivedOutputs: Array = []; + const tabChannel = testCreateMessageChannel(); + tabChannel.port2.onMessage = (output) => { + receivedOutputs.push(output); + }; + + worker.port.postMessage({ + type: "InitTab", + port: tabChannel.port1.native, + }); + + const evoluChannel = testCreateMessageChannel(); + const leaderChannel = testCreateMessageChannel< + never, + DbWorkerLeaderOutput + >(); + + worker.port.postMessage({ + type: "InitEvolu", + name: testName, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, + }); + + const entry: ConsoleEntry = { + method: "info", + path: ["DbWorker"], + args: ["initializeDb", { name: testName }], + }; + + leaderChannel.port2.postMessage({ type: "ConsoleEntry", entry }); + + expect(receivedOutputs).toContainEqual({ type: "ConsoleEntry", entry }); + }); + + test("accepts LeaderAcquired events from leader channel", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + const evoluChannel = testCreateMessageChannel(); + const leaderChannel = testCreateMessageChannel< + never, + DbWorkerLeaderOutput + >(); + + worker.port.postMessage({ + type: "InitEvolu", + name: testName, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, + }); + + expect(() => { + leaderChannel.port2.postMessage({ + type: "LeaderAcquired", + name: testName, + }); + }).not.toThrow(); + }); + + test("accepts Evolu input messages on evolu channel", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + const evoluChannel = testCreateMessageChannel(); + const leaderChannel = testCreateMessageChannel< + never, + DbWorkerLeaderOutput + >(); + + worker.port.postMessage({ + type: "InitEvolu", + name: testName, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, + }); + + expect(() => { + evoluChannel.port2.postMessage({ + type: "mutate", + changes: [{} as MutationChange], + onCompleteIds: [], + subscribedQueries: [], + }); + }).not.toThrow(); + }); + + test("throws for unknown leader channel message type", async () => { + const { worker, workerStack } = await setupWorker(); + await using _workerStack = workerStack; + + const evoluChannel = testCreateMessageChannel(); + const leaderChannel = testCreateMessageChannel< + never, + DbWorkerLeaderOutput + >(); + + worker.port.postMessage({ + type: "InitEvolu", + name: testName, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, + }); + + expect(() => { + leaderChannel.port2.postMessage({ type: "Unknown" } as never); + }).toThrow(); + }); + test("throws for unknown message type", async () => { const { worker, workerStack } = await setupWorker(); await using _workerStack = workerStack; From 0c29b0ba15a44eac8389f1f6995ad54770518c3a Mon Sep 17 00:00:00 2001 From: Daniel Steigerwald Date: Sun, 15 Feb 2026 14:34:12 +0100 Subject: [PATCH 54/60] Wired DbWorker init payload and worker output flow Added full DbWorker init inputs (consoleLevel, dbSchema, encryptionKey) and wired them from createEvolu through worker channels. Unified leader output typing with EvoluTabOutput, added typed EvoluError forwarding, and updated SharedWorker InitTab to receive/apply consoleLevel. Updated schema conversion typing to avoid external casts, wired sqlite driver in web Db worker run deps, and aligned SharedWorker tests with the new InitTab contract. (cherry picked from commit 57d23902edfa4062fbfe2904e2479881ae3f5ee5) --- packages/common/src/local-first/Db.ts | 7 ++---- packages/common/src/local-first/Shared.ts | 13 ++++++++-- .../common/test/local-first/Shared.test.ts | 24 ++++++++++++------- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index db6f118ff..989d0c2f0 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -14,7 +14,7 @@ import type { WorkerDeps, WorkerSelf, } from "../Worker.js"; -import type { ConsoleEntry } from "../Console.js"; +import type { EvoluTabOutput } from "./Shared.js"; export interface DbWorkerInput { readonly type: "init"; @@ -35,10 +35,7 @@ export type DbWorkerLeaderOutput = readonly type: "LeaderAcquired"; readonly name: SimpleName; } - | { - readonly type: "ConsoleEntry"; - readonly entry: ConsoleEntry; - }; + | EvoluTabOutput; export const initDbWorker = ( diff --git a/packages/common/src/local-first/Shared.ts b/packages/common/src/local-first/Shared.ts index 6f4244a31..4988aa0e8 100644 --- a/packages/common/src/local-first/Shared.ts +++ b/packages/common/src/local-first/Shared.ts @@ -6,7 +6,7 @@ import type { NonEmptyReadonlyArray } from "../Array.js"; import type { CallbackId } from "../Callbacks.js"; -import type { ConsoleEntry } from "../Console.js"; +import type { ConsoleEntry, ConsoleLevel } from "../Console.js"; import { exhaustiveCheck } from "../Function.js"; import { ok } from "../Result.js"; import type { Task } from "../Task.js"; @@ -46,6 +46,7 @@ export type SharedWorkerInput = | { /** Tab-level channel for broadcast outputs (console/error). */ readonly type: "InitTab"; + readonly consoleLevel: ConsoleLevel; readonly port: NativeMessagePort; } | { @@ -106,11 +107,12 @@ export const initSharedWorker = console.info("initSharedWorker"); self.onConnect = (port) => { - console.info("onConnect"); + console.debug("onConnect"); port.onMessage = (message) => { switch (message.type) { case "InitTab": { + console.setLevel(message.consoleLevel); const tabPort = createMessagePort(message.port); tabPorts.add(tabPort); @@ -148,6 +150,13 @@ export const initSharedWorker = }); break; } + case "EvoluError": { + postOrQueueTabOutput({ + type: "EvoluError", + error: leaderEvent.error, + }); + break; + } default: exhaustiveCheck(leaderEvent); } diff --git a/packages/common/test/local-first/Shared.test.ts b/packages/common/test/local-first/Shared.test.ts index 69bb22bb3..332794dbb 100644 --- a/packages/common/test/local-first/Shared.test.ts +++ b/packages/common/test/local-first/Shared.test.ts @@ -66,6 +66,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: consoleChannel.port1.native, }); @@ -96,6 +97,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: consoleChannel.port1.native, }); @@ -125,6 +127,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: consoleChannel.port1.native, }); @@ -153,6 +156,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: consoleChannel.port1.native, }); @@ -181,6 +185,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: consoleChannel.port1.native, }); @@ -208,6 +213,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: consoleChannel.port1.native, }); @@ -235,6 +241,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: consoleChannel.port1.native, }); @@ -281,6 +288,7 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitTab", + consoleLevel: "debug", port: tabChannel.port1.native, }); @@ -293,8 +301,8 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitEvolu", name: testName, - port1: evoluChannel.port1.native, - port2: leaderChannel.port1.native, + port: evoluChannel.port1.native, + brokerPort: leaderChannel.port1.native, }); const entry: ConsoleEntry = { @@ -321,8 +329,8 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitEvolu", name: testName, - port1: evoluChannel.port1.native, - port2: leaderChannel.port1.native, + port: evoluChannel.port1.native, + brokerPort: leaderChannel.port1.native, }); expect(() => { @@ -346,8 +354,8 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitEvolu", name: testName, - port1: evoluChannel.port1.native, - port2: leaderChannel.port1.native, + port: evoluChannel.port1.native, + brokerPort: leaderChannel.port1.native, }); expect(() => { @@ -373,8 +381,8 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitEvolu", name: testName, - port1: evoluChannel.port1.native, - port2: leaderChannel.port1.native, + port: evoluChannel.port1.native, + brokerPort: leaderChannel.port1.native, }); expect(() => { From 8b424a805c4fcdcbc4bd8fb81d5ca5c8122e63ac Mon Sep 17 00:00:00 2001 From: Miccy Date: Mon, 16 Feb 2026 06:21:09 +0100 Subject: [PATCH 55/60] chore(sync): apply biome fixes after shared worker backport --- packages/common/src/local-first/Db.ts | 10 ++++++---- packages/common/test/local-first/Shared.test.ts | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index 989d0c2f0..a4a2adfdd 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -55,10 +55,12 @@ export const initDbWorker = createMessagePort(nativeBrokerPort), ); - const unsubscribeConsoleStore = consoleStoreOutputEntry.subscribe(() => { - const entry = consoleStoreOutputEntry.get(); - if (entry) leaderPort.postMessage({ type: "ConsoleEntry", entry }); - }); + const unsubscribeConsoleStore = consoleStoreOutputEntry.subscribe( + () => { + const entry = consoleStoreOutputEntry.get(); + if (entry) leaderPort.postMessage({ type: "ConsoleEntry", entry }); + }, + ); stack.defer(() => { unsubscribeConsoleStore(); diff --git a/packages/common/test/local-first/Shared.test.ts b/packages/common/test/local-first/Shared.test.ts index 332794dbb..cb9afac96 100644 --- a/packages/common/test/local-first/Shared.test.ts +++ b/packages/common/test/local-first/Shared.test.ts @@ -2,13 +2,13 @@ import { describe, expect, test } from "vitest"; import type { ConsoleEntry } from "../../src/Console.js"; import { testCreateConsole } from "../../src/Console.js"; import type { DbWorkerLeaderOutput } from "../../src/local-first/Db.js"; +import type { MutationChange } from "../../src/local-first/Schema.js"; import { type EvoluInput, type EvoluTabOutput, initSharedWorker, type SharedWorkerInput, } from "../../src/local-first/Shared.js"; -import type { MutationChange } from "../../src/local-first/Schema.js"; import type { ReadonlyStore } from "../../src/Store.js"; import { createStore } from "../../src/Store.js"; import { testCreateRun, testName } from "../../src/Test.js"; From dc4c658e122818a5ea008eab0d34b484031d99eb Mon Sep 17 00:00:00 2001 From: Miccy Date: Mon, 16 Feb 2026 08:03:27 +0100 Subject: [PATCH 56/60] chore(sync): align common Db/Shared contract with common-v8 --- packages/common/src/Type.ts | 15 + packages/common/src/local-first/Db.ts | 387 ++++++++++++++++-- packages/common/src/local-first/Shared.ts | 166 ++++++-- .../common/test/local-first/Shared.test.ts | 39 +- 4 files changed, 523 insertions(+), 84 deletions(-) diff --git a/packages/common/src/Type.ts b/packages/common/src/Type.ts index f1cb2191a..608139bb3 100644 --- a/packages/common/src/Type.ts +++ b/packages/common/src/Type.ts @@ -1658,6 +1658,21 @@ export const SimpleName = /*#__PURE__*/ brand( export type SimpleName = typeof SimpleName.Type; export interface SimpleNameError extends TypeError<"SimpleName"> {} +/** + * @deprecated Use {@link SimpleName}. Kept for upstream/common-v8 API parity. + */ +export const Name = SimpleName; + +/** + * @deprecated Use {@link SimpleName}. Kept for upstream/common-v8 API parity. + */ +export type Name = SimpleName; + +/** + * @deprecated Use {@link SimpleNameError}. Kept for upstream/common-v8 API parity. + */ +export type NameError = SimpleNameError; + /** * Trimmed string between 8 and 64 characters, branded as `SimplePassword`. * diff --git a/packages/common/src/local-first/Db.ts b/packages/common/src/local-first/Db.ts index a4a2adfdd..7c9aa7269 100644 --- a/packages/common/src/local-first/Db.ts +++ b/packages/common/src/local-first/Db.ts @@ -4,22 +4,49 @@ * @module */ +import { firstInArray } from "../Array.js"; +import { assertNonEmptyReadonlyArray } from "../Assert.js"; +import type { CallbackId } from "../Callbacks.js"; +import type { ConsoleLevel } from "../Console.js"; +import type { EncryptionKey, RandomBytesDep } from "../Crypto.js"; +import { getProperty } from "../Object.js"; import type { LeaderLockDep } from "../Platform.js"; -import { ok } from "../Result.js"; +import { getOk, ok } from "../Result.js"; +import type { CreateSqliteDriverDep, SqliteDep } from "../Sqlite.js"; +import { createSqlite, type SqliteValue, sql } from "../Sqlite.js"; import type { AsyncDisposableStack, Task } from "../Task.js"; -import type { SimpleName } from "../Type.js"; +import type { Name } from "../Type.js"; +import { type Id, type IdBytes, idBytesToId } from "../Type.js"; import type { + MessagePort, NativeMessagePort, Worker, WorkerDeps, WorkerSelf, } from "../Worker.js"; +import type { OwnerId, OwnerIdBytes } from "./Owner.js"; +import { ownerIdBytesToOwnerId } from "./Owner.js"; +import { protocolVersion } from "./Protocol.js"; +import type { Query } from "./Query.js"; +import type { DbSchema, MutationChange } from "./Schema.js"; +import { ensureDbSchema, getDbSchema, systemColumns } from "./Schema.js"; import type { EvoluTabOutput } from "./Shared.js"; +import { createBaseSqliteStorageTables } from "./Storage.js"; +import type { Timestamp } from "./Timestamp.js"; +import { + createInitialTimestamp, + type TimestampBytes, + timestampBytesToTimestamp, + timestampToTimestampBytes, +} from "./Timestamp.js"; export interface DbWorkerInput { - readonly type: "init"; - readonly name: SimpleName; - readonly brokerPort: NativeMessagePort; + readonly type: "Init"; + readonly name: Name; + readonly consoleLevel: ConsoleLevel; + readonly dbSchema: DbSchema; + readonly encryptionKey: EncryptionKey; + readonly port: NativeMessagePort; } export type DbWorker = Worker; @@ -30,35 +57,64 @@ export interface CreateDbWorkerDep { readonly createDbWorker: CreateDbWorker; } +export type DbWorkerDeps = WorkerDeps & LeaderLockDep & CreateSqliteDriverDep; + +export interface DbWorkerLeaderInput { + readonly type: "Mutate"; + readonly requestId: CallbackId; + readonly changes: ReadonlyArray; + readonly onCompleteIds: ReadonlyArray; + readonly subscribedQueries: ReadonlyArray; +} + export type DbWorkerLeaderOutput = | { readonly type: "LeaderAcquired"; - readonly name: SimpleName; + readonly name: Name; + } + | { + readonly type: "OnMutate"; + readonly requestId: CallbackId; } | EvoluTabOutput; +export interface PortDep { + readonly port: MessagePort; +} + export const initDbWorker = ( self: WorkerSelf, - ): Task => + ): Task => (run) => { const { leaderLock, createMessagePort, consoleStoreOutputEntry } = run.deps; const stack = run.stack(); let initialized = false; - self.onMessage = ({ name, brokerPort: nativeBrokerPort }) => { + self.onMessage = ({ + name, + consoleLevel, + dbSchema, + encryptionKey, + port: nativeLeaderPort, + }) => { if (!initialized) { initialized = true; - - const leaderPort = stack.use( - createMessagePort(nativeBrokerPort), + const console = run.deps.console.child(name).child("DbWorker"); + console.setLevel(consoleLevel); + console.info("initDbWorker"); + + const port = stack.use( + createMessagePort( + nativeLeaderPort, + ), ); const unsubscribeConsoleStore = consoleStoreOutputEntry.subscribe( () => { const entry = consoleStoreOutputEntry.get(); - if (entry) leaderPort.postMessage({ type: "ConsoleEntry", entry }); + if (entry) port.postMessage({ type: "ConsoleEntry", entry }); }, ); @@ -69,8 +125,11 @@ export const initDbWorker = void run.daemon(async (run) => { await stack.use(leaderLock.acquire(name)); - leaderPort.postMessage({ type: "LeaderAcquired", name }); - return run(startDbWorker(name)); + console.info("leaderAcquired"); + port.postMessage({ type: "LeaderAcquired", name }); + return run.addDeps({ port })( + startDbWorker(name, dbSchema, encryptionKey), + ); }); } }; @@ -79,19 +138,300 @@ export const initDbWorker = }; const startDbWorker = - (name: SimpleName): Task => - (run) => { - const console = run.deps.console.child(name).child("DbWorker"); + ( + name: Name, + dbSchema: DbSchema, + encryptionKey: EncryptionKey, + ): Task => + async (run) => { + await using stack = run.stack(); + const { port: _port } = run.deps; + const _console = run.deps.console.child(name).child("DbWorker"); + + const sqlite = getOk( + await stack.use(createSqlite(name, { mode: "encrypted", encryptionKey })), + ); + + const deps = { ...run.deps, sqlite }; - console.info("initializeDb", { name }); + sqlite.transaction(() => { + const currentSchema = getDbSchema(deps)(); + const dbIsInitialized = "evolu_version" in currentSchema.tables; + + const clock = createClock(deps)(dbIsInitialized); + + if (!dbIsInitialized) initializeDb(deps)(clock.get()); + + ensureDbSchema(deps)(dbSchema, currentSchema); + + tryApplyQuarantinedMessages(deps)(dbSchema); + }); + + _port.onMessage = (message) => { + _port.postMessage({ type: "OnMutate", requestId: message.requestId }); + }; + + // run.deps.leaderPort.onMessage({ + // // + // }) + + return ok(stack.move()); // TODO: Add parallel stale-leader detection. // Heartbeat is emitted by the active DB worker and sent to // SharedWorker. SharedWorker tracks last-seen heartbeat per Evolu // name and if silent for 10 seconds, it waits for another DB worker // to announce itself alive and then routes requests to that worker. + }; + +interface Clock { + readonly get: () => Timestamp; + readonly save: (timestamp: Timestamp) => void; +} - return ok(); +// interface ClockDep { +// readonly clock: Clock; +// } + +const createClock = + (deps: RandomBytesDep & SqliteDep) => + (dbIsInitialized: boolean): Clock => { + let currentTimestamp: Timestamp; + + if (dbIsInitialized) { + const { rows } = deps.sqlite.exec<{ clock: TimestampBytes }>(sql` + select clock + from evolu_config + limit 1; + `); + assertNonEmptyReadonlyArray(rows); + currentTimestamp = timestampBytesToTimestamp(firstInArray(rows).clock); + } else { + currentTimestamp = createInitialTimestamp(deps); + } + + return { + get: () => currentTimestamp, + + save: (timestamp) => { + currentTimestamp = timestamp; + + deps.sqlite.exec(sql.prepared` + update evolu_config + set "clock" = ${timestampToTimestampBytes(timestamp)}; + `); + }, + }; + }; + +const initializeDb = + ({ sqlite }: SqliteDep) => + (initialClock: Timestamp): void => { + for (const query of [ + sql` + create table evolu_version ( + "protocolVersion" integer not null + ) + strict; + `, + + sql` + insert into evolu_version ("protocolVersion") + values (${protocolVersion}); + `, + + sql` + create table evolu_config ( + "clock" blob not null + ) + strict; + `, + + sql` + insert into evolu_config ("clock") + values (${timestampToTimestampBytes(initialClock)}); + `, + + /** + * The History table stores all values per ownerId, timestamp, table, id, + * and column for conflict-free merging using last-write-win CRDT. + * Denormalizes Timestamp and DbChange for covering index performance. + * Time travel is available when last-write-win isn't desired. Future + * optimization will store history more efficiently. + */ + sql` + create table evolu_history ( + "ownerId" blob not null, + "table" text not null, + "id" blob not null, + "column" text not null, + "timestamp" blob not null, + "value" any + ) + strict; + `, + + // Index for reading database changes by owner and timestamp. + sql` + create index evolu_history_ownerId_timestamp on evolu_history ( + "ownerId", + "timestamp" + ); + `, + + sql` + create unique index evolu_history_ownerId_table_id_column_timestampDesc on evolu_history ( + "ownerId", + "table", + "id", + "column", + "timestamp" desc + ); + `, + + /** + * Stores messages with unknown schema in a quarantine table. + * + * When a device receives sync messages containing tables or columns that + * don't exist in its current schema (e.g., from a newer app version), + * those messages are stored here instead of being discarded. This enables + * forward compatibility: + * + * 1. Unknown data is preserved and can be applied when the app is updated + * 2. Messages are still propagated to other devices that may understand them + * 3. Partial messages work - known columns go to app tables, unknown to + * quarantine + * + * The `union all` query in `readDbChange` combines `evolu_history` and + * this table, ensuring all data (known and unknown) is included when + * syncing to other devices. + */ + sql` + create table evolu_message_quarantine ( + "ownerId" blob not null, + "timestamp" blob not null, + "table" text not null, + "id" blob not null, + "column" text not null, + "value" any, + primary key ("ownerId", "timestamp", "table", "id", "column") + ) + strict; + `, + ]) { + sqlite.exec(query); + } + + createBaseSqliteStorageTables({ sqlite }); + }; + +const tryApplyQuarantinedMessages = + (deps: SqliteDep) => + (dbSchema: DbSchema): void => { + const rows = deps.sqlite.exec<{ + readonly ownerId: OwnerIdBytes; + readonly timestamp: TimestampBytes; + readonly table: string; + readonly id: IdBytes; + readonly column: string; + readonly value: SqliteValue; + }>(sql` + select "ownerId", "timestamp", "table", "id", "column", "value" + from evolu_message_quarantine; + `); + + for (const row of rows.rows) { + if (!validateColumnValue(dbSchema, row.table, row.column, row.value)) + continue; + + applyColumnChange(deps)( + row.ownerId, + ownerIdBytesToOwnerId(row.ownerId), + row.table, + row.id, + idBytesToId(row.id), + row.column, + row.value, + row.timestamp, + ); + + deps.sqlite.exec(sql` + delete from evolu_message_quarantine + where + "ownerId" = ${row.ownerId} + and "timestamp" = ${row.timestamp} + and "table" = ${row.table} + and "id" = ${row.id} + and "column" = ${row.column}; + `); + } + }; + +const validateColumnValue = ( + dbSchema: DbSchema, + table: string, + column: string, + _value: SqliteValue, +): boolean => { + const schemaColumns = getProperty(dbSchema.tables, table); + return ( + schemaColumns != null && + (systemColumnsWithoutOwnerId.has(column) || schemaColumns.has(column)) + ); +}; + +const systemColumnsWithoutOwnerId = systemColumns.difference( + new Set(["ownerId"]), +); + +const applyColumnChange = + (deps: SqliteDep) => + ( + ownerIdBytes: OwnerIdBytes, + ownerId: OwnerId, + table: string, + idBytes: IdBytes, + id: Id, + column: string, + value: SqliteValue, + timestampBytes: TimestampBytes, + ): void => { + deps.sqlite.exec(sql.prepared` + with + existingTimestamp as ( + select 1 + from evolu_history + where + "ownerId" = ${ownerIdBytes} + and "table" = ${table} + and "id" = ${idBytes} + and "column" = ${column} + and "timestamp" >= ${timestampBytes} + limit 1 + ) + insert into ${sql.identifier(table)} + ("ownerId", "id", ${sql.identifier(column)}) + select ${ownerId}, ${id}, ${value} + where not exists (select 1 from existingTimestamp) + on conflict ("ownerId", "id") do update + set ${sql.identifier(column)} = ${value} + where not exists (select 1 from existingTimestamp); + `); + + deps.sqlite.exec(sql.prepared` + insert into evolu_history + ("ownerId", "table", "id", "column", "value", "timestamp") + values + ( + ${ownerIdBytes}, + ${table}, + ${idBytes}, + ${column}, + ${value}, + ${timestampBytes} + ) + on conflict do nothing; + `); }; // import { @@ -159,7 +499,6 @@ const startDbWorker = // createSync, // SyncDep, // SyncOwner, -// tryApplyQuarantinedMessages, // } from "./Sync.js"; // import { // Timestamp, @@ -528,14 +867,6 @@ const startDbWorker = // }); // if (!sync.ok) return sync; -// { -// const result = tryApplyQuarantinedMessages({ -// ...deps, -// dbSchema: initMessage.dbSchema, -// })(); -// if (!result.ok) return result; -// } - // sync.value.useOwner(true, appOwner); // return ok({ diff --git a/packages/common/src/local-first/Shared.ts b/packages/common/src/local-first/Shared.ts index 4988aa0e8..73a17e7c6 100644 --- a/packages/common/src/local-first/Shared.ts +++ b/packages/common/src/local-first/Shared.ts @@ -6,11 +6,13 @@ import type { NonEmptyReadonlyArray } from "../Array.js"; import type { CallbackId } from "../Callbacks.js"; +import { createCallbacks } from "../Callbacks.js"; import type { ConsoleEntry, ConsoleLevel } from "../Console.js"; import { exhaustiveCheck } from "../Function.js"; import { ok } from "../Result.js"; -import type { Task } from "../Task.js"; -import type { SimpleName } from "../Type.js"; +import { exponential, jitter, maxDelay } from "../Schedule.js"; +import { sleep, type Task, timeout } from "../Task.js"; +import type { Name } from "../Type.js"; import type { SharedWorker as CommonSharedWorker, MessagePort, @@ -18,7 +20,7 @@ import type { SharedWorkerSelf, WorkerDeps, } from "../Worker.js"; -import type { DbWorkerLeaderOutput } from "./Db.js"; +import type { DbWorkerLeaderInput, DbWorkerLeaderOutput } from "./Db.js"; import type { EvoluError } from "./Error.js"; import type { Query } from "./Query.js"; import type { MutationChange } from "./Schema.js"; @@ -36,7 +38,7 @@ export interface SharedWorkerDep { * request variants will be added as query and owner flows are implemented. */ export interface EvoluInput { - readonly type: "mutate"; + readonly type: "Mutate"; readonly changes: NonEmptyReadonlyArray; readonly onCompleteIds: ReadonlyArray; readonly subscribedQueries: ReadonlyArray; @@ -52,9 +54,12 @@ export type SharedWorkerInput = | { /** Per-Evolu instance request channel. */ readonly type: "InitEvolu"; - readonly name: SimpleName; - readonly port: NativeMessagePort; - readonly brokerPort: NativeMessagePort; + readonly name: Name; + readonly port1: NativeMessagePort; + readonly port2: NativeMessagePort< + DbWorkerLeaderInput, + DbWorkerLeaderOutput + >; }; export type EvoluTabOutput = @@ -79,24 +84,100 @@ export const initSharedWorker = const tabPorts = new Set>(); const queuedTabOutputs: Array = []; const leaderPorts = new Map< - SimpleName, - MessagePort + Name, + MessagePort >(); + const mutationQueues = new Map>(); + const queueProcessors = new Set(); + const onMutateCallbacks = createCallbacks(run.deps); + + const retrySchedule = jitter(0.5)(maxDelay("5s")(exponential("250ms"))); const postTabOutput = (output: EvoluTabOutput): void => { - for (const port of tabPorts) port.postMessage(output); + if (tabPorts.size === 0) queuedTabOutputs.push(output); + else for (const port of tabPorts) port.postMessage(output); }; - const postOrQueueTabOutput = (output: EvoluTabOutput): void => { - if (tabPorts.size === 0) queuedTabOutputs.push(output); - else postTabOutput(output); + const waitForAck = + (ackPromise: Promise): Task => + async () => { + await ackPromise; + return ok(); + }; + + const ensureMutationQueue = (name: Name): Array => { + let queue = mutationQueues.get(name); + if (!queue) { + queue = []; + mutationQueues.set(name, queue); + } + return queue; + }; + + const processMutationQueue = (name: Name): void => { + if ((mutationQueues.get(name)?.length ?? 0) === 0) return; + if (queueProcessors.has(name)) return; + queueProcessors.add(name); + + void run.daemon(async (run) => { + const retryStep = retrySchedule(run.deps); + + while ((mutationQueues.get(name)?.length ?? 0) > 0) { + const queue = mutationQueues.get(name); + if (!queue) break; + + const message = queue[0]; + const leaderPort = leaderPorts.get(name); + + if (!leaderPort) { + const retry = retryStep(undefined); + if (!retry.ok) break; + const r = await run(sleep(retry.value[1])); + if (!r.ok) break; + continue; + } + + if (!message.requestId) { + const { promise, resolve } = Promise.withResolvers(); + message.requestId = onMutateCallbacks.register(resolve); + message.ackPromise = promise; + } + + leaderPort.postMessage({ + type: "Mutate", + requestId: message.requestId, + changes: message.changes, + onCompleteIds: message.onCompleteIds, + subscribedQueries: message.subscribedQueries, + }); + + const ackPromise = message.ackPromise; + if (!ackPromise) continue; + + const ack = await run(timeout(waitForAck(ackPromise), "3s")); + + if (ack.ok) { + queue.shift(); + continue; + } + + const retry = retryStep(undefined); + if (!retry.ok) break; + const r = await run(sleep(retry.value[1])); + if (!r.ok) break; + } + + queueProcessors.delete(name); + processMutationQueue(name); + return ok(); + }); }; await using stack = run.stack(); const unsubscribeConsoleStore = consoleStoreOutputEntry.subscribe(() => { const entry = consoleStoreOutputEntry.get(); - if (entry) postOrQueueTabOutput({ type: "ConsoleEntry", entry }); + if (entry) postTabOutput({ type: "ConsoleEntry", entry }); }); stack.defer(() => { @@ -124,46 +205,47 @@ export const initSharedWorker = break; } case "InitEvolu": { - // TODO: Wrap port, do async init (open DB, load owner), - // then set onMessage to start processing requests. - // Messages are queued until onMessage is assigned. + const evoluName = message.name; const evoluPort = createMessagePort( - message.port, - ); - const brokerPort = createMessagePort( - message.brokerPort, + message.port1, ); + const leaderPort = createMessagePort< + DbWorkerLeaderInput, + DbWorkerLeaderOutput + >(message.port2); - leaderPorts.set(message.name, brokerPort); + leaderPorts.set(evoluName, leaderPort); - brokerPort.onMessage = (leaderEvent: DbWorkerLeaderOutput) => { - switch (leaderEvent.type) { + leaderPort.onMessage = (message) => { + switch (message.type) { case "LeaderAcquired": { - leaderPorts.set(leaderEvent.name, brokerPort); - console.info("leaderAcquired", { name: leaderEvent.name }); + leaderPorts.set(message.name, leaderPort); + console.info("leaderAcquired", { name: message.name }); + processMutationQueue(message.name); break; } - case "ConsoleEntry": { - postOrQueueTabOutput({ - type: "ConsoleEntry", - entry: leaderEvent.entry, - }); + case "OnMutate": { + onMutateCallbacks.execute(message.requestId); break; } + case "ConsoleEntry": case "EvoluError": { - postOrQueueTabOutput({ - type: "EvoluError", - error: leaderEvent.error, - }); + postTabOutput(message); break; } default: - exhaustiveCheck(leaderEvent); + exhaustiveCheck(message); } }; - evoluPort.onMessage = (message: EvoluInput) => { - console.log(message); + evoluPort.onMessage = (message) => { + const queue = ensureMutationQueue(evoluName); + queue.push({ + changes: message.changes, + onCompleteIds: message.onCompleteIds, + subscribedQueries: message.subscribedQueries, + }); + processMutationQueue(evoluName); }; break; } @@ -175,3 +257,11 @@ export const initSharedWorker = return ok(stack.move()); }; + +interface QueuedMutation { + readonly changes: NonEmptyReadonlyArray; + readonly onCompleteIds: ReadonlyArray; + readonly subscribedQueries: ReadonlyArray; + requestId?: CallbackId; + ackPromise?: Promise; +} diff --git a/packages/common/test/local-first/Shared.test.ts b/packages/common/test/local-first/Shared.test.ts index cb9afac96..e7db6e329 100644 --- a/packages/common/test/local-first/Shared.test.ts +++ b/packages/common/test/local-first/Shared.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "vitest"; import type { ConsoleEntry } from "../../src/Console.js"; import { testCreateConsole } from "../../src/Console.js"; -import type { DbWorkerLeaderOutput } from "../../src/local-first/Db.js"; +import type { + DbWorkerLeaderInput, + DbWorkerLeaderOutput, +} from "../../src/local-first/Db.js"; import type { MutationChange } from "../../src/local-first/Schema.js"; import { type EvoluInput, @@ -261,8 +264,8 @@ describe("initSharedWorker", () => { await using _workerStack = workerStack; const evoluChannel = testCreateMessageChannel(); - const brokerChannel = testCreateMessageChannel< - never, + const leaderChannel = testCreateMessageChannel< + DbWorkerLeaderInput, DbWorkerLeaderOutput >(); @@ -270,8 +273,8 @@ describe("initSharedWorker", () => { worker.port.postMessage({ type: "InitEvolu", name: testName, - port: evoluChannel.port1.native, - brokerPort: brokerChannel.port1.native, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, }); }).not.toThrow(); }); @@ -294,15 +297,15 @@ describe("initSharedWorker", () => { const evoluChannel = testCreateMessageChannel(); const leaderChannel = testCreateMessageChannel< - never, + DbWorkerLeaderInput, DbWorkerLeaderOutput >(); worker.port.postMessage({ type: "InitEvolu", name: testName, - port: evoluChannel.port1.native, - brokerPort: leaderChannel.port1.native, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, }); const entry: ConsoleEntry = { @@ -322,15 +325,15 @@ describe("initSharedWorker", () => { const evoluChannel = testCreateMessageChannel(); const leaderChannel = testCreateMessageChannel< - never, + DbWorkerLeaderInput, DbWorkerLeaderOutput >(); worker.port.postMessage({ type: "InitEvolu", name: testName, - port: evoluChannel.port1.native, - brokerPort: leaderChannel.port1.native, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, }); expect(() => { @@ -347,20 +350,20 @@ describe("initSharedWorker", () => { const evoluChannel = testCreateMessageChannel(); const leaderChannel = testCreateMessageChannel< - never, + DbWorkerLeaderInput, DbWorkerLeaderOutput >(); worker.port.postMessage({ type: "InitEvolu", name: testName, - port: evoluChannel.port1.native, - brokerPort: leaderChannel.port1.native, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, }); expect(() => { evoluChannel.port2.postMessage({ - type: "mutate", + type: "Mutate", changes: [{} as MutationChange], onCompleteIds: [], subscribedQueries: [], @@ -374,15 +377,15 @@ describe("initSharedWorker", () => { const evoluChannel = testCreateMessageChannel(); const leaderChannel = testCreateMessageChannel< - never, + DbWorkerLeaderInput, DbWorkerLeaderOutput >(); worker.port.postMessage({ type: "InitEvolu", name: testName, - port: evoluChannel.port1.native, - brokerPort: leaderChannel.port1.native, + port1: evoluChannel.port1.native, + port2: leaderChannel.port1.native, }); expect(() => { From 4f17691c60749fe29d41d1362bb16a2d4ddafdf1 Mon Sep 17 00:00:00 2001 From: Miccy Date: Mon, 16 Feb 2026 08:03:45 +0100 Subject: [PATCH 57/60] chore(sync): keep Bun/Web workers on experimental Db protocol aliases --- packages/bun/src/BunDbWorker.ts | 8 ++++---- packages/bun/test/BunDbWorker.test.ts | 5 ++++- packages/common/src/local-first/index.ts | 24 +++++++++++++++++++++++- packages/web/src/local-first/DbWorker.ts | 10 +++++----- packages/web/test/DbWorker.test.ts | 8 ++++---- 5 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/bun/src/BunDbWorker.ts b/packages/bun/src/BunDbWorker.ts index da9242d2f..72e04c956 100644 --- a/packages/bun/src/BunDbWorker.ts +++ b/packages/bun/src/BunDbWorker.ts @@ -14,10 +14,10 @@ import { type SQLQueryBindings, } from "bun:sqlite"; import type { - DbWorkerInput, - DbWorkerMutateResponseMessage, - DbWorkerOutput, - DbWorkerQueryResponseMessage, + ExperimentalDbWorkerInput as DbWorkerInput, + ExperimentalDbWorkerMutateResponseMessage as DbWorkerMutateResponseMessage, + ExperimentalDbWorkerOutput as DbWorkerOutput, + ExperimentalDbWorkerQueryResponseMessage as DbWorkerQueryResponseMessage, Row, } from "@evolu/common/local-first"; diff --git a/packages/bun/test/BunDbWorker.test.ts b/packages/bun/test/BunDbWorker.test.ts index d3f48f9cb..9c9cf46d9 100644 --- a/packages/bun/test/BunDbWorker.test.ts +++ b/packages/bun/test/BunDbWorker.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import type { DbWorkerInput, DbWorkerOutput } from "@evolu/common/local-first"; +import type { + ExperimentalDbWorkerInput as DbWorkerInput, + ExperimentalDbWorkerOutput as DbWorkerOutput, +} from "@evolu/common/local-first"; import { type BunDbWorkerScope, runBunDbWorkerScope, diff --git a/packages/common/src/local-first/index.ts b/packages/common/src/local-first/index.ts index 623cac209..607c8593d 100644 --- a/packages/common/src/local-first/index.ts +++ b/packages/common/src/local-first/index.ts @@ -1,4 +1,26 @@ -export * from "./DbWorkerProtocol.js"; +export * from "./Db.js"; +export { + type DbWorkerCloseMessage as ExperimentalDbWorkerCloseMessage, + type DbWorkerCloseResponseMessage as ExperimentalDbWorkerCloseResponseMessage, + type DbWorkerErrorMessage as ExperimentalDbWorkerErrorMessage, + type DbWorkerExportMessage as ExperimentalDbWorkerExportMessage, + type DbWorkerExportResponseMessage as ExperimentalDbWorkerExportResponseMessage, + type DbWorkerGetAppOwnerMessage as ExperimentalDbWorkerGetAppOwnerMessage, + type DbWorkerInitMessage as ExperimentalDbWorkerInitMessage, + type DbWorkerInitResponseMessage as ExperimentalDbWorkerInitResponseMessage, + type DbWorkerInput as ExperimentalDbWorkerInput, + type DbWorkerLeaderInput as ExperimentalDbWorkerLeaderInput, + type DbWorkerLeaderOutput as ExperimentalDbWorkerLeaderOutput, + type DbWorkerMutateMessage as ExperimentalDbWorkerMutateMessage, + type DbWorkerMutateResponseMessage as ExperimentalDbWorkerMutateResponseMessage, + type DbWorkerOutput as ExperimentalDbWorkerOutput, + type DbWorkerQueryMessage as ExperimentalDbWorkerQueryMessage, + type DbWorkerQueryResponseMessage as ExperimentalDbWorkerQueryResponseMessage, + type DbWorkerResetMessage as ExperimentalDbWorkerResetMessage, + type DbWorkerResetResponseMessage as ExperimentalDbWorkerResetResponseMessage, + dbWorkerLeaderHeartbeatIntervalMs as experimentalDbWorkerLeaderHeartbeatIntervalMs, + dbWorkerLeaderHeartbeatTimeoutMs as experimentalDbWorkerLeaderHeartbeatTimeoutMs, +} from "./DbWorkerProtocol.js"; export * from "./Error.js"; export * from "./Evolu.js"; export * from "./Owner.js"; diff --git a/packages/web/src/local-first/DbWorker.ts b/packages/web/src/local-first/DbWorker.ts index 634c63e3b..e74ff5a02 100644 --- a/packages/web/src/local-first/DbWorker.ts +++ b/packages/web/src/local-first/DbWorker.ts @@ -9,13 +9,13 @@ import type { import { SimpleName as SimpleNameType } from "@evolu/common"; import type { AppOwner, - DbWorkerInput, - DbWorkerLeaderInput, - DbWorkerLeaderOutput, - DbWorkerOutput, + ExperimentalDbWorkerInput as DbWorkerInput, + ExperimentalDbWorkerLeaderInput as DbWorkerLeaderInput, + ExperimentalDbWorkerLeaderOutput as DbWorkerLeaderOutput, + ExperimentalDbWorkerOutput as DbWorkerOutput, Row, } from "@evolu/common/local-first"; -import { dbWorkerLeaderHeartbeatTimeoutMs as defaultHeartbeatTimeoutMs } from "@evolu/common/local-first"; +import { experimentalDbWorkerLeaderHeartbeatTimeoutMs as defaultHeartbeatTimeoutMs } from "@evolu/common/local-first"; import { createWasmSqliteDriver } from "../Sqlite.js"; import { createRun } from "../Task.js"; diff --git a/packages/web/test/DbWorker.test.ts b/packages/web/test/DbWorker.test.ts index efaed1cf4..5276740da 100644 --- a/packages/web/test/DbWorker.test.ts +++ b/packages/web/test/DbWorker.test.ts @@ -1,10 +1,10 @@ import type { MessagePort } from "@evolu/common"; import { SimpleName } from "@evolu/common"; import type { - DbWorkerInput, - DbWorkerLeaderInput, - DbWorkerLeaderOutput, - DbWorkerOutput, + ExperimentalDbWorkerInput as DbWorkerInput, + ExperimentalDbWorkerLeaderInput as DbWorkerLeaderInput, + ExperimentalDbWorkerLeaderOutput as DbWorkerLeaderOutput, + ExperimentalDbWorkerOutput as DbWorkerOutput, } from "@evolu/common/local-first"; import { describe, expect, test } from "vitest"; import { From a725c36da9458a70e3d560268e6b443b5cbeb2e5 Mon Sep 17 00:00:00 2001 From: Miccy Date: Mon, 16 Feb 2026 08:03:52 +0100 Subject: [PATCH 58/60] docs(sync): update common-v8 parity matrix and remaining divergence --- .../common-v8-parity-plan-2026-02-16.md | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/.ai/knowledge/common-v8-parity-plan-2026-02-16.md b/.ai/knowledge/common-v8-parity-plan-2026-02-16.md index 500ff0c00..ad0752ea8 100644 --- a/.ai/knowledge/common-v8-parity-plan-2026-02-16.md +++ b/.ai/knowledge/common-v8-parity-plan-2026-02-16.md @@ -37,21 +37,21 @@ Source: `git cherry -v miccy-dev upstream/common-v8` | `31e07eb` | Implement mutation methods | `verified-equivalent` | `createEvolu` mutation dispatch/coalescing is implemented and covered; queue processing is serialized across microtasks. | | `da75834` | Rename worker module to Shared | `verified-equivalent` | `Shared.ts` is now present and exported; legacy `Worker.ts` stays as compatibility layer. | | `019ad76` | Add testCreateWorker and worker tests | `verified-equivalent` | Backported: `testCreateWorker`, `testCreateMessageChannel`, `testCreateMessagePort`, `testCreateSharedWorker` + `packages/common/test/Worker.test.ts`. | -| `b90fae5` | Wire createEvolu to DB worker init | `pending-adopt` | Re-check against current `createEvolu` + DB worker init payload flow. | -| `d4fb5ba` | Wire platform DB workers and worker rename | `pending-adopt` | Port behavior without forced file/module rename. | -| `a986d23` | Add leader lock and worker init wiring | `pending-adopt` | Leader logic exists locally; verify contract and edge-cases against upstream. | +| `b90fae5` | Wire createEvolu to DB worker init | `verified-equivalent` | Local runtime already wires InitEvolu + DB worker init flow; covered by local-first Evolu tests. | +| `d4fb5ba` | Wire platform DB workers and worker rename | `intentional-divergence` | Keep Bun-first Worker/DbWorker layout, but `local-first` now exports upstream `Db` contract and moves fork-only protocol under `Experimental*` aliases. | +| `a986d23` | Add leader lock and worker init wiring | `verified-equivalent` | LeaderLock wiring is present across common/web/react-native runtime deps and tests. | | `cc87fde` | Add canonical test name constants | `verified-equivalent` | `testName`/`testAppName` present locally. | | `857608f` | Rename common test deps module | `verified-equivalent` | Test helper imports now converge on `_deps.ts`; `_deps.nodejs.ts` left as compatibility re-export shim. | | `d5e7e3c` | Add React Native leader lock tests | `verified-equivalent` | RN leader lock tests are present and green. | | `27a34ec` | Add web leader lock browser tests | `verified-equivalent` | Web leader lock browser tests are present and green. | | `cb1e7e0` | Use store-only worker console output | `verified-equivalent` | Worker bootstrap now uses `createWorkerRun` + store output forwarding path. | | `f05ec75` | Use Run type for run.daemon | `verified-equivalent` | Already ported in local fork. | -| `af7aca4` | Update TreeShaking.test.ts | `pending-adopt` | Compare fixture/snapshot intent, keep Bun-compatible tooling. | +| `af7aca4` | Update TreeShaking.test.ts | `intentional-divergence` | Bun-first keeps normalized tree-shaking snapshot to stabilize cross-runtime size variance. | | `2cc6272` | Sort imports | `intentional-divergence` | Non-functional; handled by Biome formatting policy. | | `2a585dc` | Centralize deterministic test values | `verified-equivalent` | Already ported. | | `fb46547` | Use deterministic AppOwner in playground | `verified-equivalent` | Already ported. | -| `fefc8e3` | Refactor db worker init flow | `pending-adopt` | Reconcile with Bun-first worker runtime path. | -| `db759d4` | Broker channel wiring + leader acquired output | `pending-adopt` | Compare contract edges and callbacks end-to-end. | +| `fefc8e3` | Refactor db worker init flow | `verified-equivalent` | Stack ownership + daemon init flow is already present in local worker startup path. | +| `db759d4` | Broker channel wiring + leader acquired output | `verified-equivalent` | Leader channel wiring and `LeaderAcquired` handling are implemented and regression-tested. | | `a2a448d` | Typed NativeMessagePort generics | `verified-equivalent` | Already ported. | | `746ea84` | Refactored local-first worker channel wiring | `verified-equivalent` | Already ported with Bun-first file layout. | | `0b390f6` | Clarified SharedWorker communication comment | `verified-equivalent` | Doc-level parity covered in local comments/docs. | @@ -59,12 +59,20 @@ Source: `git cherry -v miccy-dev upstream/common-v8` | `57d2390` | Wired DbWorker init payload and worker output flow | `verified-equivalent` | Ported with local worker protocol/wiring. | | `d4f0e5f` | Wired sqlite driver into React Native deps | `verified-equivalent` | Ported. | | `096326e` | Documented type aliases for composed deps | `intentional-divergence` | Upstream `apps/web` docs were removed from this fork; docs parity is tracked in `website/apps/docs`. | -| `7f12041` | Refactored DB worker startup to initialize schema/bootstrap clock | `pending-adopt` | Needs selective backport into Bun worker path. | +| `7f12041` | Refactored DB worker startup to initialize schema/bootstrap clock | `verified-equivalent` | `Db.ts` is now aligned with upstream startup/bootstrap flow (clock + schema init + quarantine reapply) with Bun-first cleanup only. | | `f94006d` | SQLite throw-first semantics + protocol/storage alignment | `verified-equivalent` | Ported in local fork (conflict-resolved variant). | | `1f7be0d` | Update pnpm-lock.yaml | `intentional-divergence` | Bun lockfile only. | | `af2e3f6` | Use getOk in SQLite setup paths | `verified-equivalent` | Ported. | | `717abb4` | Upgrade Biome to v2.4.0 and update config | `verified-equivalent` | Ported via local dependency/config update. | -| `c089ae9` | Queue mutations, add quarantine, rename messages | `pending-adopt` | Major functional gap to finish. | +| `c089ae9` | Queue mutations, add quarantine, rename messages | `intentional-divergence` | Upstream `Db.ts`/`Shared.ts` queue+ack path is now aligned; local `Evolu.ts` keeps Bun-first direct `DbWorkerClient` queue path as internal runtime divergence. Public API remains compatible. | + +## Remaining Blocking Gaps + +No unresolved blocking gaps in the tracked `common-v8` matrix. + +Open divergence to track: + +1. `c089ae9` runtime path in `Evolu.ts` remains intentionally Bun-first (direct DbWorker client queue) instead of upstream SharedWorker mutation queue orchestration. ## Execution Order From b3db45a6350916c0c9359975abb5f61ccda8de87 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 08:19:01 +0000 Subject: [PATCH 59/60] Initial plan From cad3fbd536225e142ed32312373d5986ed6ed815 Mon Sep 17 00:00:00 2001 From: Miccy Date: Mon, 16 Feb 2026 09:31:20 +0100 Subject: [PATCH 60/60] feat: remove dotenv@16.6.1 from bun.lock --- bun.lock | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bun.lock b/bun.lock index 4bc6e2ba9..4207bafaf 100644 --- a/bun.lock +++ b/bun.lock @@ -3542,8 +3542,6 @@ "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], - "app-builder-lib/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], - "app-builder-lib/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "app-builder-lib/minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="], @@ -3600,8 +3598,6 @@ "dmg-license/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], - "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], - "electron/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], "electron-builder/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],