Browser runtime: platform seam, SQLite WASM adapter, worker host - #18
Conversation
Milestone M1 of the in-browser runtime plan (#17). The browser build needs the shared modules free of Node built-in imports, so the seams land before any WASM storage work: - src/platform/context-store.ts defines a ContextStore interface with a registered factory. context.ts, transaction-context.ts, and deadline.ts no longer import node:async_hooks. Node entry points register an AsyncLocalStorage factory; TurnContextStore covers a future browser host with serialized turns. - src/platform/uuid.ts routes UUID generation through the standard crypto.randomUUID(), so shared modules drop node:crypto. - scripts/check-browser-imports.mjs walks the browser-safe import graph and fails the check when a node: module or server driver reaches it. Wired into pnpm run check.
Milestone M2 of the in-browser runtime plan (#17). solid-objects/database/sqlite-wasm implements the Database contract on @sqlite.org/sqlite-wasm (optional peer dependency), with the same serialized-access, deadline, and transaction semantics as the Node SQLite adapter and a distinct solid-objects-wasm-v1 schema identity. Storage modes: temporary (default) and persistent, which uses the OPFS SAH pool VFS and fails fast where OPFS is unavailable. Coverage: the full runtime passes a round-trip test against the adapter in Node, and a Playwright test drives the adapter inside a module worker in Chromium, proving transactions, rollback, and OPFS persistence across a page reload. The browser test server vends the sqlite-wasm assets and rewrites the bare specifier for module workers, which cannot read page import maps.
First stage of milestone M3 in the in-browser runtime plan (#17). solid-objects/browser/host registers the browser platform on import (turn-scoped context store, browser host identity) and re-exports the core runtime API plus the WASM adapter, so a worker needs one import. Two seams complete the node-free runtime graph: - src/platform/host-identity.ts carries hostname, host process id, and runtime version; repository.ts drops node:os and process.pid. The node platform module (renamed to src/platform/node.js) registers the Node identity. - serialization.ts sizes payloads with TextEncoder instead of the Buffer global, which the import-graph check cannot see. A Playwright test runs the complete runtime inside a Chromium module worker on OPFS storage and proves durable actor state across a page reload. check:browser-imports now walks the whole runtime graph from src/browser/host.ts, so a future node: import fails the check.
Greptile SummaryThe PR introduces browser platform registration, SQLite WASM storage, multi-tab runtime hosting, and a transactional synchronization bridge.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Sequence DiagramsequenceDiagram
participant T as Browser Tab
participant C as BroadcastChannel
participant L as Elected Leader
participant R as Solid Objects Runtime
participant D as SQLite WASM / OPFS
T->>C: Invoke with request ID
C->>L: Deliver invocation
L->>R: Enqueue idempotent message
R->>D: Commit actor state and work
D-->>R: Durable result
R-->>L: Invocation result
L-->>C: Broadcast result
C-->>T: Resolve request
Reviews (3): Last reviewed commit: "fix: enforce transaction deadlines witho..." | Re-trigger Greptile |
Completes #17. M3: solid-objects/browser/tab-host gives many tabs one runtime. Every tab starts a candidate host; the Web Locks API elects one leader per origin, and only the leader opens the OPFS database and runs workers. Tabs invoke actors over a BroadcastChannel client that retries with idempotent request ids, so a resend after failover applies once. The plan named a SharedWorker host; Web Locks election between dedicated workers replaced it because OPFS sync access handles exist only in dedicated workers. A Playwright test proves shared durable state across two tabs and continuation after the leader tab closes. M4: solid-objects/sync-bridge drains the local effects outbox to a server runtime. Actors stage sync intents with emit() in the same transaction as their state change. The drain delivers at-least-once and in per-actor order: a claimed effect transmits every undelivered sibling up to its own mailbox sequence, oldest first, and the server ingest deduplicates on the effect id. The first design held newer effects with a retryable error; it burned retry attempts under contention because effect claims tie-break on random UUIDs, so the ordered drain replaced it. Two supporting fixes came out of the browser end-to-end tests: - TurnContextStore now scopes only the synchronous part of a callback. The old promise-scoped store leaked open transaction scopes into interleaved tasks and tripped inside-transaction guards on every concurrent invocation. Browser guards are best-effort; Node keeps AsyncLocalStorage semantics. - The WASM adapter passes forceReinitIfPreviouslyFailed to the SAH pool, because the upstream module caches a failed initialization and a new leader could never claim the pool after the old tab died.
- Recheck the database deadline before COMMIT in the SQLite WASM adapter, so a transaction that outlives its budget rolls back with DatabaseDeadlineExceeded instead of committing (new regression test). - Walk export-from edges in check:browser-imports. The check previously skipped re-export graphs, so a Node builtin behind 'export ... from' could pass; src/index.ts now correctly fails when used as a root, and the browser-safe roots still pass. - Remove the unknown-typed seams the review flagged: the context store factory is generic end to end, the tab host validates channel messages with a typed guard and returns DeepReadonly<JsonValue>, the WASM adapter types result rows as Record<string, SqlValue>, and the sync bridge validates parsed effect arguments instead of casting. - Document the exact guard boundary of the turn-scoped context store: synchronous scoping restores in strict stack order, so interleaved turns cannot observe another turn's scope; ambient guards read as unset after the first await inside an actor operation.
|
@greptile-apps review |
The CI floor job runs Node 24.4.0, which predates navigator.locks. The tab host is a browser feature; the Node tests need Node 24.5 or newer, so the suite skips where the API is missing, the same way the external-database suites skip without a server. docs/api.md records the requirement, and startTabHost keeps its fail-fast error.
Greptile round two found the browser gap in the round-one fix: the turn-scoped context store drops the ambient deadline when a transaction callback first suspends, so the pre-COMMIT check saw no deadline in the browser and committed anyway. The WASM adapter now captures the absolute expiry synchronously at access entry, while the ambient store is still visible, and enforces that captured deadline in every statement and before COMMIT. The serialized access queue makes the instance-level deadline race-free. Node keeps the ambient checks as before. A Playwright regression runs the exact scenario in Chromium with the turn-scoped store: a transaction that outlives its deadline now rolls back and leaves no rows.
|
@greptile-apps review |
The PR previously updated only the CI-enforced documents (api.md, parity.md, CHANGELOG.md). This closes the gaps the audit found: - support.md: add the SQLite WASM optional peer and browser runtime rows, the OPFS and Web Locks platform requirements with the Node 24.5 navigator.locks boundary, and the browser runtime suites in the matrix description. - browser-protocol.md: document the two new wire surfaces, the tab host BroadcastChannel protocol (versioned envelopes, idempotent request ids, origin trust boundary) and the sync envelope with its idempotent server ingest. - architecture.md: describe the platform context-store seam, the SQLite WASM adapter's captured deadlines, the browser host layers (worker ownership, Web Locks election, failover), and the sync bridge; the closing scope line now names the browser. - correctness.md: record the browser guard boundary under limitations; durable fencing and ordering do not depend on the ambient guards. - fit.md: add the local-first use case. - README.md: add the 'Solid Objects in the browser' section with the worker example and companions, the browser requirements, and the browser runtime in the early-release coverage note.
…ime-prd # Conflicts: # README.md # docs/architecture.md # docs/correctness.md
package.json and src/version.ts already carry 0.14.0; the pending release now includes the browser runtime, so the Unreleased section folds into the 0.14.0 entry and the entry takes today's date. The parity ledger section retitles from work-in-progress to the shipped JavaScript-only capability.
solid-objects/database/shared-sqlite-wasm removes the visible tab infrastructure. Every tab runs the ordinary configure -> install -> ref flow against one shared database; the adapter hides the coordination: - The Web Locks API elects one holder per origin. Only the holder opens the OPFS pool; an election loop retries after a failed open so a broken candidate does not strand the lock. - Every other instance carries its SQL over BroadcastChannel sessions (open, statements, commit/rollback/end) that run through the holder's serialized access queue, so cross-tab semantics equal the single-connection semantics. - Requests carry the holder epoch. A new holder rejects stale-epoch requests, so clients learn about failover from a fast rejection instead of a timeout. A session that has not executed a statement retries automatically; later failures surface, because replaying partially executed work is not safe. An idle-session watchdog reclaims the slot when a tab dies mid-session. - The runtime's existing leases and fencing arbitrate the tabs' workers, the same way they arbitrate Node processes. Vitest covers cross-instance statements, transactions, serialization, two full runtimes on one shared database, failover, and the session watchdog. Playwright proves plain actor references incrementing one durable counter from two tabs with failover after the holder tab closes. The README example now leads with this API; tab-host remains the request-level alternative.
Same boundary as the tab host: the CI floor runs Node 24.4.0, which predates navigator.locks. The election loop also stops with one onError report instead of a retry spin when the API is missing, so a constructor on old Node stays quiet and close() works normally.
this.mirror().increment({ amount }) stages the sync effect the same
way schedule() and sendTo() stage their intents, so the common case
(replay the operation on the server twin of the same actor) needs no
effect name or nested arguments object. emit(SYNC_BRIDGE_EFFECT, ...)
remains the staging surface when the target differs from the source.
The constant moved to src/sync-effect.ts so the actor base class and
the bridge share it without a cycle.
sync already means synchronous in this package (SyncTimeout, SyncInsideTransaction, syncPollingIntervalMilliseconds), and the bridge family gave the word a second meaning while its own staging verb was mirror(). One concept now carries one name end to end: an actor mirrors an operation, the host registers the mirror with a transmit callback, and the server receives mirror envelopes. - solid-objects/sync-bridge -> solid-objects/mirror - registerSyncBridge -> registerMirror (SyncBridgeOptions -> RegisterMirrorOptions); the transmit callback keeps its name, it is the transport, not the mirror - receiveSyncEnvelope -> receiveMirrorEnvelope; SyncEnvelope -> MirrorEnvelope; InvalidSyncEnvelope -> InvalidMirrorEnvelope - SYNC_BRIDGE_EFFECT -> MIRROR_EFFECT, value solid-objects.mirror Nothing has shipped to npm, so the wire value change breaks nobody.
receiveMirrorEnvelope appeared as a bare call with no request context. The api reference now shows it inside a Fetch-style POST handler with authentication before ingest and a 422 note for rejected envelopes. The same pass finishes the mirror rename in prose and code strings: mirror intent, mirror effect, and a mirror:<effectId> idempotency key instead of the leftover sync: prefix.
One name now covers the whole feature: this.transmit() stages the intent, registerTransmit drains the outbox, TransmitEnvelope crosses the wire, and receiveTransmitEnvelope applies it on the server. The wire-attempt callback renames to deliver, so transmit keeps one meaning (the durable staged relationship) and deliver keeps the other (one network attempt that may fail and retry). - solid-objects/mirror -> solid-objects/transmit - registerMirror -> registerTransmit; RegisterMirrorOptions -> RegisterTransmitOptions with deliver instead of transmit - receiveMirrorEnvelope -> receiveTransmitEnvelope; MirrorEnvelope -> TransmitEnvelope; InvalidMirrorEnvelope -> InvalidTransmitEnvelope - MIRROR_EFFECT -> TRANSMIT_EFFECT, value solid-objects.transmit; ingest idempotency key transmit:<effectId> - actor.mirror() -> actor.transmit() Nothing has shipped to npm, so the wire value change breaks nobody.
|
The server-side counterpart for the transmit family in Ruby is proposed in cardmagic/solid-objects-ruby#47: |
The transmit drain and ingest were argued portable but only exercised on SQLite. Each real-server suite now runs the full round trip: two runtimes share one database under distinct table prefixes, an actor stages through transmit(), the drain survives a failed delivery and keeps per-actor order, and the server ingest deduplicates a doubled delivery. Verified locally against PostgreSQL and MySQL 8.4 before push; the existing CI database jobs now cover it on every run.
|
Manual cross-runtime QA against the Rails backend (cardmagic/solid-objects-ruby#49): a Node runtime staged transmits into a real Rails app over HTTP, and a Rails app staged transmits into a Node ingest. One contract bug surfaced; everything else held. Bug: const argumentsValue = envelope.arguments ?? {}
if (!isJsonObject(argumentsValue)) {
throw new InvalidPayload("sync envelope arguments must be a JSON object")
}and enqueue What held in both directions:
|
Cross-runtime QA (solid-objects-ruby#49) found the one contract disagreement: the Ruby ingest and the JS staging side both default a missing arguments field to an empty object, while the JS ingest rejected it with a 422. receiveTransmitEnvelope now applies the same default, so the shared fixture case passes both ingests identically. Two contract pins came out of the same QA: - a test that an envelope without arguments applies with defaults; - a test that a replay with changed arguments raises IdempotencyConflict and leaves the first application intact. The api reference handler now maps InvalidPayload and IdempotencyConflict to 422 explicitly, because both mark permanently unappliable envelopes and a 500 would make the sending outbox retry them forever. The leftover 'sync envelope' error strings become 'transmit envelope'.
Add compatibility/transmit-envelopes.json, the golden envelope file the Ruby repo committed in solid-objects-ruby#49, with a consuming suite. The valid fixtures apply exactly once, the duplicate pair applies once, every malformed fixture rejects, and a staged envelope matches the fixture byte for byte apart from the generated effect id. The wire contract is now enforced from both sides of the repo boundary by one shared file instead of two sets of inline literals.
51dc949 to
78f49ea
Compare
Ruby PR solid-objects-ruby#49 adopts the whole transmit family, so the parity ledger no longer files it under the JavaScript-only browser runtime. A new section records the shared wire contract (camelCase keys, optional arguments defaulting to an empty object, the transmit:<effectId> idempotency key, conflict-on-changed-replay) and the cross-runtime QA that validated it in both directions. The branch already carried the shared fixture and its consuming suite; this commit keeps that suite as the single consumer and adds the one assertion it lacked: the idempotency key the ingest actually stores matches the fixture's pinned key, row for row.
Implements all four milestones of #17: the full Solid Objects runtime runs inside a browser, stores actor state in SQLite WASM on OPFS, shares one runtime across tabs, and syncs its outbox to a server runtime.
The shape
One import gives a browser worker the whole programming model, and multi-tab coordination is invisible. This code runs identically in every tab:
State survives page reloads (the same actor id loads the same durable OPFS state), and every tab sees the same counter:
sharedSqliteWasmelects one database holder per origin through the Web Locks API, carries the other tabs' SQL to it over aBroadcastChannel, and fails over onto the same durable state when the holder's tab dies. For a single dedicated worker,sqliteWasm({ storage: "persistent" })opens the database directly.With the shared database, typed references work in every tab, because every tab runs a full runtime and the existing leases and fencing arbitrate their workers exactly as they arbitrate Node processes. The tab host below is the request-level alternative: one runtime on the leader, other tabs invoking by name over a channel.
Many tabs, one runtime (request-level alternative)
sharedSqliteWasmabove is the primary multi-tab path.solid-objects/browser/tab-hostremains for a different resource profile: exactly one worker set per origin. The Web Locks API elects a leader, only the leader runs a runtime, and the other tabs stay thin — no workers, no polling — invoking by name over aBroadcastChannelclient with idempotent request ids (startTabHost/connectTabClient, documented indocs/api.md). Choose it when most tabs only issue commands and should not run background work; choose the shared database when every tab wants typed references and the full runtime. Failover works the same way in both: the leader's death releases the lock and the next candidate promotes on the same OPFS state.Offline transmit through the transactional outbox
An actor stages a sync intent in the same transaction as its state change, with the same fluent shape as
schedule()andsendTo(). The effect worker drains the outbox with at-least-once delivery, per-actor order, and retry backoff; the server ingest deduplicates on the effect id.The browser side, in the worker that hosts the local runtime:
The server side is one route in the host application. On a Node backend — shown Fetch-style, any HTTP framework works:
The backend does not have to be Node. cardmagic/solid-objects-ruby#47 proposes the same ingest for the Ruby gem over the identical wire contract, so a solid-objects-js browser front end can replay its operations onto Rails server actors:
Both ingests dedup on the same
transmit:<effectId>idempotency key and accept the same camelCase envelope, so the browser side cannot tell which runtime answered.transmit()covers the common case (same actor type and id on both sides).emit(TRANSMIT_EFFECT, ...)stages the same intent with an explicit target when the server actor differs.What is underneath
AsyncLocalStoragein Node, a turn-scoped store in the browser), one seam supplies host identity, andcheck:browser-importsfails the build when anode:import or a server driver reaches the browser graph — including throughexport ... fromedges.solid-objects/database/sqlite-wasmimplements theDatabasecontract on@sqlite.org/sqlite-wasm(optional peer dependency) with the same serialized-access, deadline, and transaction semantics as the Node SQLite adapter. Deadlines survive without ambient context: the adapter captures the absolute expiry at access entry and enforces it in every statement and before COMMIT.solid-objects/browser/hostregisters the browser platform on import.solid-objects/browser/tab-hostadds the runtime-level election and client.solid-objects/database/shared-sqlite-wasmmoves the election behind theDatabaseseam: the holder opens the pool, other instances run SQL sessions overBroadcastChannelthrough the holder's serialized access queue, requests carry the holder epoch so failover surfaces as a fast rejection, a not-yet-executed session retries automatically (a partially executed one never replays), and an idle-session watchdog reclaims the slot when a tab dies mid-session. The PRD named aSharedWorkerhost; Web Locks election between dedicated workers replaced it, because OPFS sync access handles exist only in dedicated workers.solid-objects/transmitrides the existing effects outbox. Per-actor order comes from an ordered drain: a claimed effect transmits every undelivered envelope for its actor up to its own mailbox sequence, oldest first, and the server dedups.Tests
floorCI job (Node 24.4.0) passes; the tab host and shared database suites skip there becausenavigator.locksarrived in Node 24.5, and the docs record that boundary.Docs
The documentation set covers the browser runtime end to end:
docs/api.mddocuments all four new entry points with failover fence tuning and offline dead-letter guidance;docs/browser-protocol.mddocuments the tab hostBroadcastChannelprotocol, the shared database channel protocol, and the sync envelope;docs/architecture.mddescribes the platform seam, the browser host layers, and the sync bridge;docs/support.mdrecords the SQLite WASM peer dependency and the OPFS and Web Locks platform boundaries;docs/correctness.mdrecords the browser guard boundary under limitations;docs/fit.mdadds the local-first use case; the README gains a 'Solid Objects in the browser' section;docs/parity.mdrecords the JavaScript-only capability with all milestones complete; andCHANGELOG.mdcarries the Unreleased notes.