From b3b1e6a95e20b47282f35422d9d8c75403793fb4 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Thu, 8 Oct 2026 07:56:17 -0700 Subject: [PATCH 1/4] docs: add the browser runtime to the agent guide The agent guide covered only Node.js, so an agent that read it could not tell when the browser runtime fits, which entry point to import, or what fails on a real page. Step 12 adds the fit, the install with the SQLite WASM peer dependency, the entry points, a module worker example, authorization in the browser, the platform limits, transmit to a Node.js or Rails server, and the checks. The example lives in examples/browser/ and the Chromium suite runs it: state survives a reload, two tabs share one draft, and the second tab continues after the holder tab closes. The failover test first failed with "actor invocation timed out after 5000ms ... waitingOn=activationHeld" under the default lease settings, so the example keeps the short lease settings that the API reference recommends, and the guide says why. check-documentation now requires the guide to embed both example files, and its link check skips fenced code, where ref[operation](value) is code and not a link. --- docs/agents.md | 199 +++++++++++++++++++- docs/releasing.md | 6 +- examples/browser/draft-worker.js | 39 ++++ examples/browser/page.js | 22 +++ scripts/check-documentation.mjs | 23 ++- test/browser-server.mjs | 15 ++ test/browser/agent-guide-example.browser.ts | 67 +++++++ 7 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 examples/browser/draft-worker.js create mode 100644 examples/browser/page.js create mode 100644 test/browser/agent-guide-example.browser.ts diff --git a/docs/agents.md b/docs/agents.md index 898dea6..28f0293 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -55,6 +55,9 @@ with Cloudflare. - Redis is optional. It only shortens wake-up latency and holds no durable state. +The browser runtime has different requirements. See +[step 12](#12-use-the-browser-runtime). + [Supported versions](support.md) lists the CI matrix. ## 4. Install @@ -325,7 +328,199 @@ When you explain Solid Objects to a user, state these limits: run. - The package is pre-1.0. It has no measured scale and no known third-party production use. -- The browser client is not the SQL runtime. The Cloudflare backend is - experimental. +- `solid-objects/browser` is a WebSocket client, not the SQL runtime. The + browser runtime in step 12 is tested in Chromium only. The Cloudflare backend + is experimental. The [correctness contract](correctness.md) is the source for each guarantee. + +## 12. Use the browser runtime + +The same `Actor` classes run in a browser module worker. The database is SQLite +WASM. Persistent state is in the origin private file system (OPFS), so it +survives a page reload. + +Use the browser runtime when one identity in the browser, such as a draft, a +game, or a form, must keep its state across reloads and tabs. It also fits +writes that must wait on the device until the network returns. Select a simpler +tool in these cases: + +| Requirement | Use instead | +| ---------------------------------------- | ----------------------------------------------- | +| A preference or another small value | `localStorage` | +| A cache of server responses | The Cache API or IndexedDB | +| State that must be correct for all users | A server runtime. The user controls the browser | +| Live updates from a server runtime only | `solid-objects/browser`, the WebSocket client | + +### Install + +```bash +npm install solid-objects @sqlite.org/sqlite-wasm +``` + +`@sqlite.org/sqlite-wasm` 3.50 or newer is an optional peer dependency. The +browser runtime needs it. + +### Choose the entry point + +| Need | Use | +| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| The runtime in a worker | `solid-objects/browser/host`. It exports `Actor`, `configure`, `sqliteWasm`, `sharedSqliteWasm`, and the tab host | +| One database for all tabs, a worker in each tab | `sharedSqliteWasm({ path })`. Storage is persistent by default | +| One worker in one tab only | `sqliteWasm({ path, storage: "persistent" })`. The default storage is `"temporary"` | +| One runtime for all tabs, with a leader tab | `startTabHost()` and `connectTabClient()` from `solid-objects/browser/tab-host` | +| Send local writes to a server | `this.transmit()` and `registerTransmit()`. See [Send writes to a server](#send-writes-to-a-server) | + +### Example + +This module worker hosts the runtime. Each tab starts one copy. +`sharedSqliteWasm` elects one tab to hold the database. The other tabs send +their SQL to that tab, and a new tab takes over when it closes. + +```javascript +import { Actor, configure, sharedSqliteWasm } from "solid-objects/browser/host" + +class NoteDraft extends Actor { + static actorType = "NoteDraft" + + text = "" + revision = 0 + + edit({ text }) { + this.text = text + this.revision += 1 + return this.revision + } +} + +const allowNoteDrafts = ({ actorType }) => actorType === "NoteDraft" + +const runtime = configure({ + database: sharedSqliteWasm({ path: "notes.db" }), + authorizeMessage: allowNoteDrafts, + authorizeQuery: allowNoteDrafts, + processAliveThresholdMilliseconds: 750, + leaseDurationMilliseconds: 750, + leaseRenewalIntervalMilliseconds: 250, +}) +runtime.register(NoteDraft) +const installed = runtime.install() +installed.then(() => runtime.run(new AbortController().signal)) + +self.onmessage = async (event) => { + const { requestId, actorId, operation, argumentsValue } = event.data + try { + await installed + const value = await NoteDraft.ref(actorId)[operation](argumentsValue) + postMessage({ requestId, ok: true, value }) + } catch (error) { + postMessage({ requestId, ok: false, message: String(error?.message ?? error) }) + } +} +``` + +The page sends messages to the worker. It does not hold actor references: + +```javascript +const worker = new Worker(new URL("./draft-worker.js", import.meta.url), { type: "module" }) +const pending = new Map() +let nextRequestId = 0 + +worker.onmessage = (event) => { + const { requestId, ok, value, message } = event.data + const request = pending.get(requestId) + pending.delete(requestId) + if (ok) { + request.resolve(value) + return + } + request.reject(new Error(message)) +} + +export function callActor({ actorId, operation, argumentsValue }) { + const requestId = nextRequestId++ + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }) + worker.postMessage({ requestId, actorId, operation, argumentsValue }) + }) +} +``` + +CI runs this example in Chromium. It checks that state survives a reload, that +two tabs share one draft, and that the second tab continues after the first +tab closes. + +A closed tab does not shut down its runtime. Keep the short lease settings in +the example. With the default settings, the next tab waits for the old lease, +and calls time out before it expires. + +### Authorize in the browser + +The page and the worker run on the user's device, and the user can change +their code. A browser policy limits what your own page can call. It is not a +security boundary. Authorize again on the server for each write that leaves the +device. + +### Rules for browser actors + +- One worker hosts one runtime. Do not import `solid-objects/browser/host` in + a process that also imports the Node.js entry points. +- After the first `await` in an operation, `currentActor()`, + `applicationWritesForbidden()`, and the database deadline read as unset. Keep + guarded writes in synchronous actor code or in commit actions. +- Reminders and effects run only while a worker that calls + `runtime.run(signal)` is alive. When the user closes every tab, nothing runs. +- With `sharedSqliteWasm`, a statement can fail with `SharedDatabaseFailover` + when the holder tab closes during the statement. Write operations so that + they can run again, and retry the call. +- With `startTabHost()`, close the database in a `catch` block when + `startRuntime` fails. An open database blocks the next tab. + +### Platform limits + +- CI tests the browser runtime in Chromium only. Safari 16.4 and Firefox 111 + added the OPFS API that persistent storage needs. Test on each engine that + you support. +- Persistent storage needs a secure context (HTTPS or `localhost`) and a + dedicated worker. `storage: "persistent"` fails fast where OPFS is missing. +- An embedded WebView, such as Cordova, WKWebView, or Android WebView, can lack + OPFS when the device browser has it. Test the WebView itself. +- The browser can clear the storage of an origin. Call + `navigator.storage.persist()` to ask it to keep the data, and send important + writes to a server. + +### Send writes to a server + +An operation stages a write for the server in the same transaction as its +state change: + +```javascript +this.transmit().edit({ text }) +``` + +`registerTransmit({ runtime, deliver })` sends each staged write to the server. +Throw from `deliver` while the device is offline, and the effect tries again +later. Set a high `maxAttempts` in `configure()`, because the default is 5 and a +long offline period can use all attempts. Run one effect worker for each local +runtime to keep the order of writes for each actor. + +The server receives the write with `receiveTransmitEnvelope({ runtime, envelope })` +in Node.js, or with `SolidObjects::Transmission.receive(envelope)` in Rails. +Authenticate the device before this call, because this delivery skips +`authorizeMessage`. Delivery is at least once. The server applies a repeated +write once, because it uses the effect ID as the idempotency key. In Node.js, +return HTTP 422 for `InvalidPayload` and `IdempotencyConflict`, so that the +device stops the retries. + +The [public API](api.md#solid-objectstransmit) and the +[browser protocol](browser-protocol.md) give the full contract. + +### Verify a browser implementation + +1. Call an operation, reload the page, and confirm that the state is the same. +2. Send concurrent calls to one identity from two tabs. Assert the final state. +3. Close the tab that holds the database. Confirm that the other tab continues. +4. On each target engine, confirm that persistent storage opens, or fails with + a clear error. +5. If you send writes to a server, confirm that the server rejects a device + that it cannot authenticate, and applies a repeated write once. diff --git a/docs/releasing.md b/docs/releasing.md index 5338466..31bfe3f 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -38,8 +38,10 @@ npm trust github solid-objects \ 3. Read `docs/virtual-actors.md` and `docs/agents.md` against the release. Correct any requirement, compatibility, or guarantee statement that the release changed. `pnpm run check:documentation` fails when the category - guide no longer embeds `examples/ticket-sale.ts`, and `pnpm run -test:package` runs that example against the packed tarball. + guide no longer embeds `examples/ticket-sale.ts`, or when the agent guide + no longer embeds the files in `examples/browser/`. `pnpm run test:package` + runs the ticket sale example against the packed tarball, and + `pnpm run test:browser` runs the browser example in Chromium. 4. Commit and push `main`. 5. Create and push an annotated tag matching the package version: diff --git a/examples/browser/draft-worker.js b/examples/browser/draft-worker.js new file mode 100644 index 0000000..83102e2 --- /dev/null +++ b/examples/browser/draft-worker.js @@ -0,0 +1,39 @@ +import { Actor, configure, sharedSqliteWasm } from "solid-objects/browser/host" + +class NoteDraft extends Actor { + static actorType = "NoteDraft" + + text = "" + revision = 0 + + edit({ text }) { + this.text = text + this.revision += 1 + return this.revision + } +} + +const allowNoteDrafts = ({ actorType }) => actorType === "NoteDraft" + +const runtime = configure({ + database: sharedSqliteWasm({ path: "notes.db" }), + authorizeMessage: allowNoteDrafts, + authorizeQuery: allowNoteDrafts, + processAliveThresholdMilliseconds: 750, + leaseDurationMilliseconds: 750, + leaseRenewalIntervalMilliseconds: 250, +}) +runtime.register(NoteDraft) +const installed = runtime.install() +installed.then(() => runtime.run(new AbortController().signal)) + +self.onmessage = async (event) => { + const { requestId, actorId, operation, argumentsValue } = event.data + try { + await installed + const value = await NoteDraft.ref(actorId)[operation](argumentsValue) + postMessage({ requestId, ok: true, value }) + } catch (error) { + postMessage({ requestId, ok: false, message: String(error?.message ?? error) }) + } +} diff --git a/examples/browser/page.js b/examples/browser/page.js new file mode 100644 index 0000000..9a5767b --- /dev/null +++ b/examples/browser/page.js @@ -0,0 +1,22 @@ +const worker = new Worker(new URL("./draft-worker.js", import.meta.url), { type: "module" }) +const pending = new Map() +let nextRequestId = 0 + +worker.onmessage = (event) => { + const { requestId, ok, value, message } = event.data + const request = pending.get(requestId) + pending.delete(requestId) + if (ok) { + request.resolve(value) + return + } + request.reject(new Error(message)) +} + +export function callActor({ actorId, operation, argumentsValue }) { + const requestId = nextRequestId++ + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }) + worker.postMessage({ requestId, actorId, operation, argumentsValue }) + }) +} diff --git a/scripts/check-documentation.mjs b/scripts/check-documentation.mjs index fc91e66..8ad036a 100644 --- a/scripts/check-documentation.mjs +++ b/scripts/check-documentation.mjs @@ -14,7 +14,8 @@ const documentationPaths = [ for (const documentationPath of documentationPaths) { const source = await readFile(resolve(repositoryRoot, documentationPath), "utf8") - for (const match of source.matchAll(/!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) { + const prose = source.replace(/^```[^\n]*\n[\s\S]*?^```$/gm, "") + for (const match of prose.matchAll(/!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) { const link = match[1] if (!link || /^(?:https?:|mailto:)/.test(link)) continue const [encodedTarget = "", encodedAnchor] = link.split("#", 2) @@ -36,12 +37,26 @@ for (const documentationPath of documentationPaths) { } const embeddedExamples = [ - { documentation: "docs/virtual-actors.md", example: "examples/ticket-sale.ts" }, + { + documentation: "docs/virtual-actors.md", + example: "examples/ticket-sale.ts", + language: "typescript", + }, + { + documentation: "docs/agents.md", + example: "examples/browser/draft-worker.js", + language: "javascript", + }, + { + documentation: "docs/agents.md", + example: "examples/browser/page.js", + language: "javascript", + }, ] -for (const { documentation, example } of embeddedExamples) { +for (const { documentation, example, language } of embeddedExamples) { const documentationSource = await readFile(resolve(repositoryRoot, documentation), "utf8") const exampleSource = await readFile(resolve(repositoryRoot, example), "utf8") - if (!documentationSource.includes(`\`\`\`typescript\n${exampleSource}\`\`\``)) { + if (!documentationSource.includes(`\`\`\`${language}\n${exampleSource}\`\`\``)) { throw new Error(`${documentation} does not embed the current ${example}`) } } diff --git a/test/browser-server.mjs b/test/browser-server.mjs index 29e012a..8366a20 100644 --- a/test/browser-server.mjs +++ b/test/browser-server.mjs @@ -9,6 +9,7 @@ const root = resolve(import.meta.dirname, "../dist") const sqliteWasmRoot = resolve(import.meta.dirname, "../node_modules/@sqlite.org/sqlite-wasm/dist") const signalPolyfillRoot = resolve(import.meta.dirname, "../node_modules/signal-polyfill/dist") const browserFixtureRoot = resolve(import.meta.dirname, "browser") +const browserExampleRoot = resolve(import.meta.dirname, "../examples/browser") const contentTypes = { ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", @@ -120,6 +121,19 @@ const server = createServer(async (request, response) => { await serveFile({ response, path: resolve(browserFixtureRoot, pathname.slice(1)) }) return } + if (pathname.startsWith("/examples/browser/")) { + const examplePath = resolve( + browserExampleRoot, + `.${pathname.slice("/examples/browser".length)}`, + ) + if (!examplePath.startsWith(`${browserExampleRoot}/`)) { + response.writeHead(404) + response.end() + return + } + await serveFile({ response, path: examplePath }) + return + } if (pathname.startsWith("/vendor/signal-polyfill/")) { const vendorPath = resolve( signalPolyfillRoot, @@ -170,6 +184,7 @@ async function serveFile({ response, path }) { .toString("utf-8") .replaceAll('"@sqlite.org/sqlite-wasm"', '"/vendor/sqlite-wasm/index.mjs"') .replaceAll('"signal-polyfill"', '"/vendor/signal-polyfill/index.js"') + .replaceAll('"solid-objects/browser/host"', '"/browser/host.js"') } response.writeHead(200, { "content-type": contentType }) response.end(contents) diff --git a/test/browser/agent-guide-example.browser.ts b/test/browser/agent-guide-example.browser.ts new file mode 100644 index 0000000..7cfc1e9 --- /dev/null +++ b/test/browser/agent-guide-example.browser.ts @@ -0,0 +1,67 @@ +import { expect, test, type Page } from "@playwright/test" + +interface DraftSnapshot { + text: string + revision: number +} + +async function openPage(page: Page): Promise { + await page.goto("/") +} + +function callActor( + page: Page, + call: { actorId: string; operation: string; argumentsValue?: unknown }, +): Promise { + return page.evaluate( + async ({ modulePath, callValue }) => { + const { callActor: callFromPage } = await import(modulePath) + return callFromPage(callValue) + }, + { modulePath: "/examples/browser/page.js", callValue: call }, + ) +} + +test("runs the agent guide example with state that survives a reload", async ({ page }) => { + const actorId = `guide-note-${Date.now()}` + await openPage(page) + + const revisions = await Promise.all([ + callActor(page, { actorId, operation: "edit", argumentsValue: { text: "first" } }), + callActor(page, { actorId, operation: "edit", argumentsValue: { text: "second" } }), + ]) + expect([...revisions].sort()).toEqual([1, 2]) + + await page.reload() + + const snapshot = (await callActor(page, { actorId, operation: "snapshot" })) as DraftSnapshot + expect(snapshot.revision).toBe(2) + expect(["first", "second"]).toContain(snapshot.text) +}) + +test("shares one draft between tabs and continues after the holder tab closes", async ({ + context, +}) => { + const actorId = `guide-tabs-${Date.now()}` + const firstTab = await context.newPage() + const secondTab = await context.newPage() + await openPage(firstTab) + await openPage(secondTab) + + expect( + await callActor(firstTab, { actorId, operation: "edit", argumentsValue: { text: "one" } }), + ).toBe(1) + expect( + await callActor(secondTab, { actorId, operation: "edit", argumentsValue: { text: "two" } }), + ).toBe(2) + + await firstTab.close() + + expect( + await callActor(secondTab, { actorId, operation: "edit", argumentsValue: { text: "three" } }), + ).toBe(3) + expect(await callActor(secondTab, { actorId, operation: "snapshot" })).toEqual({ + text: "three", + revision: 3, + }) +}) From 80c53ba93beceb38e1cc42d1d5b7fef4b5a88297 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Thu, 8 Oct 2026 07:57:35 -0700 Subject: [PATCH 2/4] chore: prepare version 0.17.3 --- CHANGELOG.md | 17 +++++++++++++++++ package.json | 2 +- src/version.ts | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 425e91e..f37acf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.17.3 - 2026-10-08 + +- `docs/agents.md` has a new step 12 for the browser runtime. It covers when + the browser runtime fits, the install with `@sqlite.org/sqlite-wasm`, the + entry points, a module worker example, authorization in the browser, the + platform limits, transmit to a Node.js or Rails server, and the checks. +- `examples/browser/draft-worker.js` and `examples/browser/page.js` are the + guide example. The Chromium suite runs them: state survives a reload, two + tabs share one draft, and the second tab continues after the holder tab + closes. `pnpm run check:documentation` fails when the guide does not embed + the current files. +- The example keeps short lease settings. With the default settings, a call + from the remaining tab timed out while it waited for the lease of the closed + tab. +- The documentation link check skips fenced code blocks, so code such as + `ref[operation](value)` is not read as a link. + ## 0.17.2 - 2026-10-08 - The README names the agent guide at the start of Installation, and the agent diff --git a/package.json b/package.json index 04c1d58..bb06a7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-objects", - "version": "0.17.2", + "version": "0.17.3", "description": "SQL-backed virtual actor library for TypeScript and Node.js, with durable state, ordered operations, and automatic activation on SQLite, PostgreSQL, or MySQL", "type": "module", "license": "MIT", diff --git a/src/version.ts b/src/version.ts index 91b8da3..e589300 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.17.2" +export const VERSION = "0.17.3" From e7b3c46ae6d4edc89413aa2c7bb9436a57caaccb Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Thu, 8 Oct 2026 08:08:13 -0700 Subject: [PATCH 3/4] fix: address review of the browser agent guide - The page example rejects waiting and later calls when the worker fails to load. A browser test routes the worker to a 404; before the fix the call timed out after 10 seconds with no answer. - registerTransmit never reads the HTTP response, so a server 422 stops retries only when deliver throws NonRetryableError. The agent guide now shows that deliver callback, docs/api.md says so, and a new test proves one attempt and a dead letter. The same test with a plain Error fails with "expected 8 to be 1". - The two-tab test now sends overlapping edits from both tabs. - The test helper uses concrete call and result types. - The example route in the test server has no nested condition. --- CHANGELOG.md | 7 ++++ docs/agents.md | 45 ++++++++++++++++++--- docs/api.md | 12 +++--- examples/browser/page.js | 8 ++++ test/browser-server.mjs | 18 ++++----- test/browser/agent-guide-example.browser.ts | 33 ++++++++++----- test/transmit.test.ts | 21 +++++++++- 7 files changed, 113 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f37acf1..244c8c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ tab. - The documentation link check skips fenced code blocks, so code such as `ref[operation](value)` is not read as a link. +- Correct the transmit docs: `registerTransmit` never reads the HTTP response, + so a server 422 stops retries only when the `deliver` callback throws + `NonRetryableError`. The agent guide shows that callback, `docs/api.md` says + so, and a new test proves that `NonRetryableError` from `deliver` + dead-letters the effect after one attempt, where a plain `Error` retries. +- The page example rejects every waiting and later call when the worker fails + to load. Before, a call waited with no end. ## 0.17.2 - 2026-10-08 diff --git a/docs/agents.md b/docs/agents.md index 28f0293..c8bb103 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -425,6 +425,7 @@ The page sends messages to the worker. It does not hold actor references: const worker = new Worker(new URL("./draft-worker.js", import.meta.url), { type: "module" }) const pending = new Map() let nextRequestId = 0 +let workerFailure worker.onmessage = (event) => { const { requestId, ok, value, message } = event.data @@ -437,7 +438,14 @@ worker.onmessage = (event) => { request.reject(new Error(message)) } +worker.onerror = (event) => { + workerFailure = new Error(`The actor worker failed: ${event.message || "it did not load"}`) + for (const request of pending.values()) request.reject(workerFailure) + pending.clear() +} + export function callActor({ actorId, operation, argumentsValue }) { + if (workerFailure) return Promise.reject(workerFailure) const requestId = nextRequestId++ return new Promise((resolve, reject) => { pending.set(requestId, { resolve, reject }) @@ -499,9 +507,35 @@ this.transmit().edit({ text }) ``` `registerTransmit({ runtime, deliver })` sends each staged write to the server. -Throw from `deliver` while the device is offline, and the effect tries again -later. Set a high `maxAttempts` in `configure()`, because the default is 5 and a -long offline period can use all attempts. Run one effect worker for each local +The runtime does not read the HTTP response. Your `deliver` callback decides +what happens: + +```javascript +import { NonRetryableError } from "solid-objects/core" +import { registerTransmit } from "solid-objects/browser/host" + +registerTransmit({ + runtime, + deliver: async (envelope) => { + const response = await fetch("/sync", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(envelope), + }) + if (response.status === 422) { + throw new NonRetryableError(`The server rejected effect ${envelope.effectId}`) + } + if (!response.ok) throw new Error(`Sync failed with HTTP ${response.status}`) + }, +}) +``` + +- A normal return marks the write as delivered. +- An `Error` makes the effect try again later. This is the offline case. +- A `NonRetryableError` moves the effect to dead letters with no retry. + +Set a high `maxAttempts` in `configure()`, because the default is 5 and a long +offline period can use all attempts. Run one effect worker for each local runtime to keep the order of writes for each actor. The server receives the write with `receiveTransmitEnvelope({ runtime, envelope })` @@ -509,8 +543,9 @@ in Node.js, or with `SolidObjects::Transmission.receive(envelope)` in Rails. Authenticate the device before this call, because this delivery skips `authorizeMessage`. Delivery is at least once. The server applies a repeated write once, because it uses the effect ID as the idempotency key. In Node.js, -return HTTP 422 for `InvalidPayload` and `IdempotencyConflict`, so that the -device stops the retries. +return HTTP 422 for `InvalidPayload` and `IdempotencyConflict`. The `deliver` +callback above turns that status into a `NonRetryableError`, so the device +stops the retries. The [public API](api.md#solid-objectstransmit) and the [browser protocol](browser-protocol.md) give the full contract. diff --git a/docs/api.md b/docs/api.md index 95f6245..a0caea6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -961,11 +961,13 @@ outbox with at-least-once delivery, per-actor order, and retry backoff. ``` The example uses a Fetch-style handler; any HTTP framework works. The 422 - matters: it tells the sending outbox to dead-letter the effect instead of - retrying it. `InvalidPayload` marks a malformed envelope, and - `IdempotencyConflict` marks a replay whose arguments changed; both are - permanently unappliable, and a 500 would make the outbox retry them - forever. + matters only when the sending `deliver` callback reads it: + `registerTransmit` never sees the HTTP response. Throw `NonRetryableError` + (from `solid-objects/core` in a browser) for a 422, and the outbox + dead-letters the effect instead of retrying it; any other error retries. + `InvalidPayload` marks a malformed envelope, and `IdempotencyConflict` + marks a replay whose arguments changed; both are permanently unappliable, + and a 500 would make the outbox retry them until `maxAttempts` runs out. - Per-actor order comes from an ordered drain: a claimed transmit effect transmits every undelivered envelope for its actor up to its own mailbox diff --git a/examples/browser/page.js b/examples/browser/page.js index 9a5767b..399171f 100644 --- a/examples/browser/page.js +++ b/examples/browser/page.js @@ -1,6 +1,7 @@ const worker = new Worker(new URL("./draft-worker.js", import.meta.url), { type: "module" }) const pending = new Map() let nextRequestId = 0 +let workerFailure worker.onmessage = (event) => { const { requestId, ok, value, message } = event.data @@ -13,7 +14,14 @@ worker.onmessage = (event) => { request.reject(new Error(message)) } +worker.onerror = (event) => { + workerFailure = new Error(`The actor worker failed: ${event.message || "it did not load"}`) + for (const request of pending.values()) request.reject(workerFailure) + pending.clear() +} + export function callActor({ actorId, operation, argumentsValue }) { + if (workerFailure) return Promise.reject(workerFailure) const requestId = nextRequestId++ return new Promise((resolve, reject) => { pending.set(requestId, { resolve, reject }) diff --git a/test/browser-server.mjs b/test/browser-server.mjs index 8366a20..8c659c4 100644 --- a/test/browser-server.mjs +++ b/test/browser-server.mjs @@ -121,16 +121,14 @@ const server = createServer(async (request, response) => { await serveFile({ response, path: resolve(browserFixtureRoot, pathname.slice(1)) }) return } - if (pathname.startsWith("/examples/browser/")) { - const examplePath = resolve( - browserExampleRoot, - `.${pathname.slice("/examples/browser".length)}`, - ) - if (!examplePath.startsWith(`${browserExampleRoot}/`)) { - response.writeHead(404) - response.end() - return - } + const isExample = pathname.startsWith("/examples/browser/") + const examplePath = resolve(browserExampleRoot, `.${pathname.slice("/examples/browser".length)}`) + if (isExample && !examplePath.startsWith(`${browserExampleRoot}/`)) { + response.writeHead(404) + response.end() + return + } + if (isExample) { await serveFile({ response, path: examplePath }) return } diff --git a/test/browser/agent-guide-example.browser.ts b/test/browser/agent-guide-example.browser.ts index 7cfc1e9..ac6e57b 100644 --- a/test/browser/agent-guide-example.browser.ts +++ b/test/browser/agent-guide-example.browser.ts @@ -9,10 +9,11 @@ async function openPage(page: Page): Promise { await page.goto("/") } -function callActor( - page: Page, - call: { actorId: string; operation: string; argumentsValue?: unknown }, -): Promise { +type DraftCall = + | { actorId: string; operation: "edit"; argumentsValue: { text: string } } + | { actorId: string; operation: "snapshot"; argumentsValue?: never } + +function callActor(page: Page, call: DraftCall): Promise { return page.evaluate( async ({ modulePath, callValue }) => { const { callActor: callFromPage } = await import(modulePath) @@ -48,12 +49,11 @@ test("shares one draft between tabs and continues after the holder tab closes", await openPage(firstTab) await openPage(secondTab) - expect( - await callActor(firstTab, { actorId, operation: "edit", argumentsValue: { text: "one" } }), - ).toBe(1) - expect( - await callActor(secondTab, { actorId, operation: "edit", argumentsValue: { text: "two" } }), - ).toBe(2) + const revisions = await Promise.all([ + callActor(firstTab, { actorId, operation: "edit", argumentsValue: { text: "one" } }), + callActor(secondTab, { actorId, operation: "edit", argumentsValue: { text: "two" } }), + ]) + expect([...revisions].sort()).toEqual([1, 2]) await firstTab.close() @@ -65,3 +65,16 @@ test("shares one draft between tabs and continues after the holder tab closes", revision: 3, }) }) + +test("rejects calls when the worker cannot load", async ({ page }) => { + test.setTimeout(10_000) + await page.route("**/examples/browser/draft-worker.js", (route) => route.fulfill({ status: 404 })) + await openPage(page) + + await expect(callActor(page, { actorId: "missing", operation: "snapshot" })).rejects.toThrow( + /worker/, + ) + await expect(callActor(page, { actorId: "missing", operation: "snapshot" })).rejects.toThrow( + /worker/, + ) +}) diff --git a/test/transmit.test.ts b/test/transmit.test.ts index c7f1d1a..e49348f 100644 --- a/test/transmit.test.ts +++ b/test/transmit.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "vitest" import "../src/platform/node.js" import { Actor } from "../src/actor.js" -import { IdempotencyConflict } from "../src/errors.js" +import { IdempotencyConflict, NonRetryableError } from "../src/errors.js" import { createRuntime, type SolidObjectsRuntime } from "../src/runtime.js" import type { SolidObjectsConfiguration } from "../src/configuration.js" import { sqlite } from "../src/database/sqlite.js" @@ -188,6 +188,25 @@ describe("sync bridge", () => { }) }) + it("dead-letters an envelope when deliver throws NonRetryableError", async () => { + let attempts = 0 + const local = testRuntime({ authorizeAdministration: () => true }) + registerTransmit({ + runtime: local, + deliver: async () => { + attempts += 1 + throw new NonRetryableError("the server answered 422") + }, + }) + await local.install() + + await local.ref(TransmitCounter, "rejected").increment({ amount: 1 }) + await local.testing.drain({ roles: ["actors", "effects"], maxPasses: 20 }) + + expect(attempts).toBe(1) + expect(await local.deadLetters.effects.all()).toHaveLength(1) + }) + it("recovers in order after an offline period", async () => { let online = false const { local, server } = await pairedRuntimes({ From a755fc7374bdec9b6a5138576beec8dc77de639b Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Thu, 8 Oct 2026 08:30:37 -0700 Subject: [PATCH 4/4] test: make the closed tab hold the shared database The overlapping edits started both workers together, so either tab could win the database lock. When the second tab won, closing the first tab did not test failover. A first call from the first tab now makes it the holder before the second tab starts its worker. --- test/browser/agent-guide-example.browser.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/browser/agent-guide-example.browser.ts b/test/browser/agent-guide-example.browser.ts index ac6e57b..31f2cd8 100644 --- a/test/browser/agent-guide-example.browser.ts +++ b/test/browser/agent-guide-example.browser.ts @@ -49,6 +49,8 @@ test("shares one draft between tabs and continues after the holder tab closes", await openPage(firstTab) await openPage(secondTab) + await callActor(firstTab, { actorId, operation: "snapshot" }) + const revisions = await Promise.all([ callActor(firstTab, { actorId, operation: "edit", argumentsValue: { text: "one" } }), callActor(secondTab, { actorId, operation: "edit", argumentsValue: { text: "two" } }),