solid-objects 0.17.2 → 0.17.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.4 - 2026-10-08
4
+
5
+ - Step 12 of `docs/agents.md` now says plainly that every authorization
6
+ callback denies by default in the browser too. A worker with no
7
+ `authorizeMessage` and no `authorizeQuery` answers no call. The install
8
+ step points to the browser policy and to `runtime.run(signal)`, and the
9
+ checks start with the policy. In an evaluation, two agent answers gave the
10
+ browser install with no policy after they read that a browser policy is
11
+ not a security boundary.
12
+ - A new Chromium test proves that a browser worker with no authorization
13
+ callbacks rejects actor calls with `Unauthorized`.
14
+
15
+ ## 0.17.3 - 2026-10-08
16
+
17
+ - `docs/agents.md` has a new step 12 for the browser runtime. It covers when
18
+ the browser runtime fits, the install with `@sqlite.org/sqlite-wasm`, the
19
+ entry points, a module worker example, authorization in the browser, the
20
+ platform limits, transmit to a Node.js or Rails server, and the checks.
21
+ - `examples/browser/draft-worker.js` and `examples/browser/page.js` are the
22
+ guide example. The Chromium suite runs them: state survives a reload, two
23
+ tabs share one draft, and the second tab continues after the holder tab
24
+ closes. `pnpm run check:documentation` fails when the guide does not embed
25
+ the current files.
26
+ - The example keeps short lease settings. With the default settings, a call
27
+ from the remaining tab timed out while it waited for the lease of the closed
28
+ tab.
29
+ - The documentation link check skips fenced code blocks, so code such as
30
+ `ref[operation](value)` is not read as a link.
31
+ - Correct the transmit docs: `registerTransmit` never reads the HTTP response,
32
+ so a server 422 stops retries only when the `deliver` callback throws
33
+ `NonRetryableError`. The agent guide shows that callback, `docs/api.md` says
34
+ so, and a new test proves that `NonRetryableError` from `deliver`
35
+ dead-letters the effect after one attempt, where a plain `Error` retries.
36
+ - The page example rejects every waiting and later call when the worker fails
37
+ to load. Before, a call waited with no end.
38
+
3
39
  ## 0.17.2 - 2026-10-08
4
40
 
5
41
  - The README names the agent guide at the start of Installation, and the agent
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.17.2";
1
+ export declare const VERSION = "0.17.4";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.17.2";
1
+ export const VERSION = "0.17.4";
2
2
  //# sourceMappingURL=version.js.map
package/docs/agents.md CHANGED
@@ -55,6 +55,9 @@ with Cloudflare.
55
55
  - Redis is optional. It only shortens wake-up latency and holds no durable
56
56
  state.
57
57
 
58
+ The browser runtime has different requirements. See
59
+ [step 12](#12-use-the-browser-runtime).
60
+
58
61
  [Supported versions](support.md) lists the CI matrix.
59
62
 
60
63
  ## 4. Install
@@ -325,7 +328,247 @@ When you explain Solid Objects to a user, state these limits:
325
328
  run.
326
329
  - The package is pre-1.0. It has no measured scale and no known third-party
327
330
  production use.
328
- - The browser client is not the SQL runtime. The Cloudflare backend is
329
- experimental.
331
+ - `solid-objects/browser` is a WebSocket client, not the SQL runtime. The
332
+ browser runtime in step 12 is tested in Chromium only. The Cloudflare backend
333
+ is experimental.
330
334
 
331
335
  The [correctness contract](correctness.md) is the source for each guarantee.
336
+
337
+ ## 12. Use the browser runtime
338
+
339
+ The same `Actor` classes run in a browser module worker. The database is SQLite
340
+ WASM. Persistent state is in the origin private file system (OPFS), so it
341
+ survives a page reload.
342
+
343
+ Use the browser runtime when one identity in the browser, such as a draft, a
344
+ game, or a form, must keep its state across reloads and tabs. It also fits
345
+ writes that must wait on the device until the network returns. Select a simpler
346
+ tool in these cases:
347
+
348
+ | Requirement | Use instead |
349
+ | ---------------------------------------- | ----------------------------------------------- |
350
+ | A preference or another small value | `localStorage` |
351
+ | A cache of server responses | The Cache API or IndexedDB |
352
+ | State that must be correct for all users | A server runtime. The user controls the browser |
353
+ | Live updates from a server runtime only | `solid-objects/browser`, the WebSocket client |
354
+
355
+ ### Install
356
+
357
+ ```bash
358
+ npm install solid-objects @sqlite.org/sqlite-wasm
359
+ ```
360
+
361
+ `@sqlite.org/sqlite-wasm` 3.50 or newer is an optional peer dependency. The
362
+ browser runtime needs it.
363
+
364
+ Every authorization callback denies by default in the browser too. Write the
365
+ policy in [Authorize in the browser](#authorize-in-the-browser) before you call
366
+ an actor. Reminders and effects run only while the worker calls
367
+ `runtime.run(signal)`.
368
+
369
+ ### Choose the entry point
370
+
371
+ | Need | Use |
372
+ | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
373
+ | The runtime in a worker | `solid-objects/browser/host`. It exports `Actor`, `configure`, `sqliteWasm`, `sharedSqliteWasm`, and the tab host |
374
+ | One database for all tabs, a worker in each tab | `sharedSqliteWasm({ path })`. Storage is persistent by default |
375
+ | One worker in one tab only | `sqliteWasm({ path, storage: "persistent" })`. The default storage is `"temporary"` |
376
+ | One runtime for all tabs, with a leader tab | `startTabHost()` and `connectTabClient()` from `solid-objects/browser/tab-host` |
377
+ | Send local writes to a server | `this.transmit()` and `registerTransmit()`. See [Send writes to a server](#send-writes-to-a-server) |
378
+
379
+ ### Example
380
+
381
+ This module worker hosts the runtime. Each tab starts one copy.
382
+ `sharedSqliteWasm` elects one tab to hold the database. The other tabs send
383
+ their SQL to that tab, and a new tab takes over when it closes.
384
+
385
+ ```javascript
386
+ import { Actor, configure, sharedSqliteWasm } from "solid-objects/browser/host"
387
+
388
+ class NoteDraft extends Actor {
389
+ static actorType = "NoteDraft"
390
+
391
+ text = ""
392
+ revision = 0
393
+
394
+ edit({ text }) {
395
+ this.text = text
396
+ this.revision += 1
397
+ return this.revision
398
+ }
399
+ }
400
+
401
+ const allowNoteDrafts = ({ actorType }) => actorType === "NoteDraft"
402
+
403
+ const runtime = configure({
404
+ database: sharedSqliteWasm({ path: "notes.db" }),
405
+ authorizeMessage: allowNoteDrafts,
406
+ authorizeQuery: allowNoteDrafts,
407
+ processAliveThresholdMilliseconds: 750,
408
+ leaseDurationMilliseconds: 750,
409
+ leaseRenewalIntervalMilliseconds: 250,
410
+ })
411
+ runtime.register(NoteDraft)
412
+ const installed = runtime.install()
413
+ installed.then(() => runtime.run(new AbortController().signal))
414
+
415
+ self.onmessage = async (event) => {
416
+ const { requestId, actorId, operation, argumentsValue } = event.data
417
+ try {
418
+ await installed
419
+ const value = await NoteDraft.ref(actorId)[operation](argumentsValue)
420
+ postMessage({ requestId, ok: true, value })
421
+ } catch (error) {
422
+ postMessage({ requestId, ok: false, message: String(error?.message ?? error) })
423
+ }
424
+ }
425
+ ```
426
+
427
+ The page sends messages to the worker. It does not hold actor references:
428
+
429
+ ```javascript
430
+ const worker = new Worker(new URL("./draft-worker.js", import.meta.url), { type: "module" })
431
+ const pending = new Map()
432
+ let nextRequestId = 0
433
+ let workerFailure
434
+
435
+ worker.onmessage = (event) => {
436
+ const { requestId, ok, value, message } = event.data
437
+ const request = pending.get(requestId)
438
+ pending.delete(requestId)
439
+ if (ok) {
440
+ request.resolve(value)
441
+ return
442
+ }
443
+ request.reject(new Error(message))
444
+ }
445
+
446
+ worker.onerror = (event) => {
447
+ workerFailure = new Error(`The actor worker failed: ${event.message || "it did not load"}`)
448
+ for (const request of pending.values()) request.reject(workerFailure)
449
+ pending.clear()
450
+ }
451
+
452
+ export function callActor({ actorId, operation, argumentsValue }) {
453
+ if (workerFailure) return Promise.reject(workerFailure)
454
+ const requestId = nextRequestId++
455
+ return new Promise((resolve, reject) => {
456
+ pending.set(requestId, { resolve, reject })
457
+ worker.postMessage({ requestId, actorId, operation, argumentsValue })
458
+ })
459
+ }
460
+ ```
461
+
462
+ CI runs this example in Chromium. It checks that state survives a reload, that
463
+ two tabs share one draft, and that the second tab continues after the first
464
+ tab closes.
465
+
466
+ A closed tab does not shut down its runtime. Keep the short lease settings in
467
+ the example. With the default settings, the next tab waits for the old lease,
468
+ and calls time out before it expires.
469
+
470
+ ### Authorize in the browser
471
+
472
+ All authorization callbacks deny by default, in the browser as in Node.js. A
473
+ worker that sets no `authorizeMessage` and no `authorizeQuery` answers no call.
474
+ Each call fails with `Unauthorized`. Allow the actor types that your page uses,
475
+ as `allowNoteDrafts` does in the example. Do not remove the deny-by-default
476
+ behavior.
477
+
478
+ The browser policy is still not a security boundary. The page and the worker
479
+ run on the user's device, and the user can change their code. The policy limits
480
+ what your own page can call. Authorize again on the server for each write that
481
+ leaves the device.
482
+
483
+ ### Rules for browser actors
484
+
485
+ - One worker hosts one runtime. Do not import `solid-objects/browser/host` in
486
+ a process that also imports the Node.js entry points.
487
+ - After the first `await` in an operation, `currentActor()`,
488
+ `applicationWritesForbidden()`, and the database deadline read as unset. Keep
489
+ guarded writes in synchronous actor code or in commit actions.
490
+ - Reminders and effects run only while a worker that calls
491
+ `runtime.run(signal)` is alive. When the user closes every tab, nothing runs.
492
+ - With `sharedSqliteWasm`, a statement can fail with `SharedDatabaseFailover`
493
+ when the holder tab closes during the statement. Write operations so that
494
+ they can run again, and retry the call.
495
+ - With `startTabHost()`, close the database in a `catch` block when
496
+ `startRuntime` fails. An open database blocks the next tab.
497
+
498
+ ### Platform limits
499
+
500
+ - CI tests the browser runtime in Chromium only. Safari 16.4 and Firefox 111
501
+ added the OPFS API that persistent storage needs. Test on each engine that
502
+ you support.
503
+ - Persistent storage needs a secure context (HTTPS or `localhost`) and a
504
+ dedicated worker. `storage: "persistent"` fails fast where OPFS is missing.
505
+ - An embedded WebView, such as Cordova, WKWebView, or Android WebView, can lack
506
+ OPFS when the device browser has it. Test the WebView itself.
507
+ - The browser can clear the storage of an origin. Call
508
+ `navigator.storage.persist()` to ask it to keep the data, and send important
509
+ writes to a server.
510
+
511
+ ### Send writes to a server
512
+
513
+ An operation stages a write for the server in the same transaction as its
514
+ state change:
515
+
516
+ ```javascript
517
+ this.transmit().edit({ text })
518
+ ```
519
+
520
+ `registerTransmit({ runtime, deliver })` sends each staged write to the server.
521
+ The runtime does not read the HTTP response. Your `deliver` callback decides
522
+ what happens:
523
+
524
+ ```javascript
525
+ import { NonRetryableError } from "solid-objects/core"
526
+ import { registerTransmit } from "solid-objects/browser/host"
527
+
528
+ registerTransmit({
529
+ runtime,
530
+ deliver: async (envelope) => {
531
+ const response = await fetch("/sync", {
532
+ method: "POST",
533
+ headers: { "content-type": "application/json" },
534
+ body: JSON.stringify(envelope),
535
+ })
536
+ if (response.status === 422) {
537
+ throw new NonRetryableError(`The server rejected effect ${envelope.effectId}`)
538
+ }
539
+ if (!response.ok) throw new Error(`Sync failed with HTTP ${response.status}`)
540
+ },
541
+ })
542
+ ```
543
+
544
+ - A normal return marks the write as delivered.
545
+ - An `Error` makes the effect try again later. This is the offline case.
546
+ - A `NonRetryableError` moves the effect to dead letters with no retry.
547
+
548
+ Set a high `maxAttempts` in `configure()`, because the default is 5 and a long
549
+ offline period can use all attempts. Run one effect worker for each local
550
+ runtime to keep the order of writes for each actor.
551
+
552
+ The server receives the write with `receiveTransmitEnvelope({ runtime, envelope })`
553
+ in Node.js, or with `SolidObjects::Transmission.receive(envelope)` in Rails.
554
+ Authenticate the device before this call, because this delivery skips
555
+ `authorizeMessage`. Delivery is at least once. The server applies a repeated
556
+ write once, because it uses the effect ID as the idempotency key. In Node.js,
557
+ return HTTP 422 for `InvalidPayload` and `IdempotencyConflict`. The `deliver`
558
+ callback above turns that status into a `NonRetryableError`, so the device
559
+ stops the retries.
560
+
561
+ The [public API](api.md#solid-objectstransmit) and the
562
+ [browser protocol](browser-protocol.md) give the full contract.
563
+
564
+ ### Verify a browser implementation
565
+
566
+ 1. Confirm that the worker sets `authorizeMessage` and `authorizeQuery`.
567
+ Without them, every call fails with `Unauthorized`.
568
+ 2. Call an operation, reload the page, and confirm that the state is the same.
569
+ 3. Send concurrent calls to one identity from two tabs. Assert the final state.
570
+ 4. Close the tab that holds the database. Confirm that the other tab continues.
571
+ 5. On each target engine, confirm that persistent storage opens, or fails with
572
+ a clear error.
573
+ 6. If you send writes to a server, confirm that the server rejects a device
574
+ that it cannot authenticate, and applies a repeated write once.
package/docs/api.md CHANGED
@@ -961,11 +961,13 @@ outbox with at-least-once delivery, per-actor order, and retry backoff.
961
961
  ```
962
962
 
963
963
  The example uses a Fetch-style handler; any HTTP framework works. The 422
964
- matters: it tells the sending outbox to dead-letter the effect instead of
965
- retrying it. `InvalidPayload` marks a malformed envelope, and
966
- `IdempotencyConflict` marks a replay whose arguments changed; both are
967
- permanently unappliable, and a 500 would make the outbox retry them
968
- forever.
964
+ matters only when the sending `deliver` callback reads it:
965
+ `registerTransmit` never sees the HTTP response. Throw `NonRetryableError`
966
+ (from `solid-objects/core` in a browser) for a 422, and the outbox
967
+ dead-letters the effect instead of retrying it; any other error retries.
968
+ `InvalidPayload` marks a malformed envelope, and `IdempotencyConflict`
969
+ marks a replay whose arguments changed; both are permanently unappliable,
970
+ and a 500 would make the outbox retry them until `maxAttempts` runs out.
969
971
 
970
972
  - Per-actor order comes from an ordered drain: a claimed transmit effect
971
973
  transmits every undelivered envelope for its actor up to its own mailbox
package/docs/releasing.md CHANGED
@@ -38,8 +38,10 @@ npm trust github solid-objects \
38
38
  3. Read `docs/virtual-actors.md` and `docs/agents.md` against the release.
39
39
  Correct any requirement, compatibility, or guarantee statement that the
40
40
  release changed. `pnpm run check:documentation` fails when the category
41
- guide no longer embeds `examples/ticket-sale.ts`, and `pnpm run
42
- test:package` runs that example against the packed tarball.
41
+ guide no longer embeds `examples/ticket-sale.ts`, or when the agent guide
42
+ no longer embeds the files in `examples/browser/`. `pnpm run test:package`
43
+ runs the ticket sale example against the packed tarball, and
44
+ `pnpm run test:browser` runs the browser example in Chromium.
43
45
  4. Commit and push `main`.
44
46
  5. Create and push an annotated tag matching the package version:
45
47
 
@@ -0,0 +1,39 @@
1
+ import { Actor, configure, sharedSqliteWasm } from "solid-objects/browser/host"
2
+
3
+ class NoteDraft extends Actor {
4
+ static actorType = "NoteDraft"
5
+
6
+ text = ""
7
+ revision = 0
8
+
9
+ edit({ text }) {
10
+ this.text = text
11
+ this.revision += 1
12
+ return this.revision
13
+ }
14
+ }
15
+
16
+ const allowNoteDrafts = ({ actorType }) => actorType === "NoteDraft"
17
+
18
+ const runtime = configure({
19
+ database: sharedSqliteWasm({ path: "notes.db" }),
20
+ authorizeMessage: allowNoteDrafts,
21
+ authorizeQuery: allowNoteDrafts,
22
+ processAliveThresholdMilliseconds: 750,
23
+ leaseDurationMilliseconds: 750,
24
+ leaseRenewalIntervalMilliseconds: 250,
25
+ })
26
+ runtime.register(NoteDraft)
27
+ const installed = runtime.install()
28
+ installed.then(() => runtime.run(new AbortController().signal))
29
+
30
+ self.onmessage = async (event) => {
31
+ const { requestId, actorId, operation, argumentsValue } = event.data
32
+ try {
33
+ await installed
34
+ const value = await NoteDraft.ref(actorId)[operation](argumentsValue)
35
+ postMessage({ requestId, ok: true, value })
36
+ } catch (error) {
37
+ postMessage({ requestId, ok: false, message: String(error?.message ?? error) })
38
+ }
39
+ }
@@ -0,0 +1,30 @@
1
+ const worker = new Worker(new URL("./draft-worker.js", import.meta.url), { type: "module" })
2
+ const pending = new Map()
3
+ let nextRequestId = 0
4
+ let workerFailure
5
+
6
+ worker.onmessage = (event) => {
7
+ const { requestId, ok, value, message } = event.data
8
+ const request = pending.get(requestId)
9
+ pending.delete(requestId)
10
+ if (ok) {
11
+ request.resolve(value)
12
+ return
13
+ }
14
+ request.reject(new Error(message))
15
+ }
16
+
17
+ worker.onerror = (event) => {
18
+ workerFailure = new Error(`The actor worker failed: ${event.message || "it did not load"}`)
19
+ for (const request of pending.values()) request.reject(workerFailure)
20
+ pending.clear()
21
+ }
22
+
23
+ export function callActor({ actorId, operation, argumentsValue }) {
24
+ if (workerFailure) return Promise.reject(workerFailure)
25
+ const requestId = nextRequestId++
26
+ return new Promise((resolve, reject) => {
27
+ pending.set(requestId, { resolve, reject })
28
+ worker.postMessage({ requestId, actorId, operation, argumentsValue })
29
+ })
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid-objects",
3
- "version": "0.17.2",
3
+ "version": "0.17.4",
4
4
  "description": "SQL-backed virtual actor library for TypeScript and Node.js, with durable state, ordered operations, and automatic activation on SQLite, PostgreSQL, or MySQL",
5
5
  "type": "module",
6
6
  "license": "MIT",