solid-objects 0.14.3 → 0.14.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/README.md CHANGED
@@ -1,95 +1,92 @@
1
- # Solid Objects JS
1
+ # Solid Objects for Node and Your Browser
2
2
 
3
3
  [![CI](https://github.com/cardmagic/solid-objects-js/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/cardmagic/solid-objects-js/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/solid-objects)](https://www.npmjs.com/package/solid-objects)
5
5
 
6
- Open Source Durable Objects for JavaScript, in the SQL database you already run.
7
- No daemon, no broker, and no new datastore.
8
-
9
- Build addressable TypeScript objects with serialized calls and durable state on
10
- SQLite, PostgreSQL, or MySQL. You don't need Cloudflare for this.
11
-
12
- Concurrent calls for one identity cannot overwrite each other. Calls for
13
- different identities can run at the same time.
14
-
15
- Define ordinary TypeScript classes and run them in ordinary Node.js processes.
16
- Solid Objects keeps the state, the queued operations, the retries, the
17
- reminders, the effects, and the realtime invalidations in the database the
18
- application already operates.
19
-
20
- The same runtime also runs inside a browser worker on SQLite WASM, with
21
- durable actor state in the origin's private file system, and its offline
22
- writes can replay onto a Node **or Rails** backend over one shared wire
23
- contract. See [Solid Objects in the browser](#solid-objects-in-the-browser).
24
-
25
- > **Early release:** the correctness core has automated coverage. That coverage
26
- > includes the supported databases, the Chromium browser client, the browser
27
- > runtime, process recovery, and the packaged artifacts. The TypeScript
28
- > implementation is still new. There is one deployed first-party reference
29
- > application. There is no measured scale and no third-party production use
30
- > yet. Read the
31
- > [delivery boundaries](#delivery-boundaries) before you use it for important
32
- > data.
33
-
34
- > **Not a replacement for SQL transactions:** when one row update inside one
35
- > transaction solves the problem, use that and install nothing. Solid Objects
36
- > earns its cost when the critical section outlives the transaction: a hold that
37
- > expires in ten minutes, work that must survive a restart, or a fan-in that
38
- > spans many jobs. See
39
- > [Why not just use transactions?](#why-not-just-use-transactions).
6
+ **Open Source Durable Objects in your Node app.**
7
+
8
+ In a shopping cart, paying twice at the same time is a big problem. The payment provider might time out, and your Node site could be restarting before recovery finishes.
9
+
10
+ To deal with this safely, you often need logic scattered between 7-10 files like database row locks, Redis locks, delayed jobs, retries, and cleanup code to keep that process straight. They are not all large, but they must agree about the same payment state and failure rules. That coordination is the difficult part.
11
+
12
+ With Solid Objects, one actor in one file owns each shopping cart's full state and recovery work. Method calls on that object run one at a time, state lives in your existing SQL database, and scheduled recovery resume after restarts.
13
+
14
+ Solid Object JavaScript Actors elegantly fit anything where one identifiable thing must remember state, handle competing requests in order, or wake up later:
15
+
16
+ - Ticket holds and reservations
17
+ - Multiplayer games and shared rooms
18
+ - Shopping carts and checkout recovery
19
+ - Rate limits and account quotas
20
+ - Session expiration
21
+ - Job leases and workflows
22
+ - Connected devices
23
+ - Collaborative documents
24
+
25
+ And so much more.
40
26
 
41
27
  ## Contents
42
28
 
43
- - [The programming model](#the-programming-model)
44
- - [Why not just use transactions?](#why-not-just-use-transactions)
45
- - [Is it worth installing here?](#is-it-worth-installing-here)
46
- - [Run it now with SQLite](#run-it-now-with-sqlite)
47
- - [What Solid Objects is for](#what-solid-objects-is-for)
48
- - [Measured behavior](#measured-behavior)
49
- - [Running in a deployed application](#running-in-a-deployed-application)
50
- - [How it works](#how-it-works)
51
- - [Delivery boundaries](#delivery-boundaries)
52
- - [Realtime committed state](#realtime-committed-state)
29
+ - [Installation](#installation)
30
+ - [An expiring ticket hold](#an-expiring-ticket-hold)
31
+ - [Why this exists](#why-this-exists)
32
+ - [Good uses](#good-uses)
53
33
  - [Solid Objects in the browser](#solid-objects-in-the-browser)
54
- - [Comparison](#comparison)
55
- - [Requirements and supported systems](#requirements-and-supported-systems)
56
- - [Operations](#operations)
57
- - [Design provenance](#design-provenance)
58
- - [Documentation](#documentation)
59
- - [License](#license)
34
+ - [When a transaction is better](#when-a-transaction-is-better)
35
+ - [Guarantees and boundaries](#guarantees-and-boundaries)
36
+ - [Read more](#read-more)
37
+ - [Status and license](#status-and-license)
38
+
39
+ ## Installation
40
+
41
+ Solid Objects is ESM-only and requires Node.js 24.4 or newer. TypeScript users
42
+ need TypeScript 5.9 or newer.
43
+
44
+ ```bash
45
+ npm install solid-objects
46
+ npx solid-objects quickstart --yes
47
+ ```
60
48
 
61
- ## The programming model
49
+ The quickstart uses Node's built-in SQLite driver and proves that concurrent
50
+ calls to one identity do not overwrite each other. It is an unusually formal
51
+ introduction to addition.
62
52
 
63
- A ticket sale for one event, with 100 seats and a hold that expires:
53
+ ## An expiring ticket hold
64
54
 
65
- ```typescript
66
- import { Actor, broadcastValue, configure } from "solid-objects"
55
+ Save this as `ticket-sale.mjs`:
56
+
57
+ ```javascript
58
+ import { Actor, configure } from "solid-objects"
67
59
  import { sqlite } from "solid-objects/database/sqlite"
68
60
 
69
- class TicketSale extends Actor {
70
- static override readonly actorType = "TicketSale"
61
+ const HOLD_MILLISECONDS = 10 * 60 * 1000
71
62
 
72
- remaining = 100
73
- holds: Record<string, number> = {}
63
+ class TicketSale extends Actor {
64
+ static actorType = "TicketSale"
65
+ available = 1
66
+ holds = {}
74
67
 
75
- override observables(): Record<string, unknown> {
76
- return { remaining: broadcastValue(this.remaining) }
77
- }
68
+ hold({ buyer }) {
69
+ if (this.available === 0 || buyer in this.holds) {
70
+ return { held: false, available: this.available }
71
+ }
78
72
 
79
- reserve({ buyer }: { buyer: string }): boolean {
80
- if (this.remaining === 0 || buyer in this.holds) return false
81
- this.remaining -= 1
73
+ this.available -= 1
82
74
  this.holds = { ...this.holds, [buyer]: Date.now() }
83
- this.schedule({ at: new Date(Date.now() + 600_000), key: buyer }).expire!({ buyer })
84
- return true
75
+ this.schedule({
76
+ at: new Date(Date.now() + HOLD_MILLISECONDS),
77
+ key: buyer,
78
+ }).expire({ buyer })
79
+ return { held: true, available: this.available }
85
80
  }
86
81
 
87
- expire({ buyer }: { buyer: string }): void {
88
- if (!(buyer in this.holds)) return
89
- const rest = { ...this.holds }
90
- delete rest[buyer]
91
- this.holds = rest
92
- this.remaining += 1
82
+ expire({ buyer }) {
83
+ if (!(buyer in this.holds)) return this.available
84
+
85
+ const remainingHolds = { ...this.holds }
86
+ delete remainingHolds[buyer]
87
+ this.holds = remainingHolds
88
+ this.available += 1
89
+ return this.available
93
90
  }
94
91
  }
95
92
 
@@ -103,276 +100,57 @@ await runtime.install()
103
100
 
104
101
  try {
105
102
  const sale = TicketSale.ref("event-42")
106
- const buyers = ["ada", "grace", "alan"]
107
- await Promise.all(buyers.map((buyer) => sale.reserve({ buyer })))
103
+
104
+ if ((process.argv[2] ?? "hold") === "work") {
105
+ const controller = new AbortController()
106
+ process.once("SIGINT", () => controller.abort())
107
+ process.once("SIGTERM", () => controller.abort())
108
+ await runtime.run(controller.signal)
109
+ } else {
110
+ console.log(await Promise.all(["ada", "grace"].map((buyer) => sale.hold({ buyer }))))
111
+ }
108
112
  } finally {
109
113
  await runtime.close()
110
114
  }
111
115
  ```
112
116
 
113
- Every reserve enters the durable mailbox for `event-42`. They execute in order
114
- and commit one state transition at a time, even when different requests or
115
- Node.js processes submit them concurrently, so the guard on `remaining` cannot
116
- oversell.
117
-
118
- That example wants three things from the same number. It must never go below
119
- zero. It must give the seat back if the buyer does not pay within ten minutes.
120
- It must show the current count to everyone watching the page.
121
-
122
- The first is one UPDATE statement. The second is an `expiresAt` column plus a
123
- sweeper. The third is a push on every code path that changes the number. The
124
- combination is what costs, not any one of them. Here the guard, the ten-minute
125
- alarm, and the published count are one class, and they commit together.
126
-
127
- `install()` prepares the database and starts nothing. The example above finishes
128
- because the caller's own path executes each call. A process serves background
129
- work only after `runtime.run(signal)` starts its roles, so a process that
130
- installs and then waits never claims a ready message. Nothing is lost while no
131
- process runs. The message stays ready until one does.
132
-
133
- ```typescript
134
- const controller = new AbortController()
135
- process.on("SIGTERM", () => controller.abort())
136
- await runtime.run(controller.signal)
137
- ```
138
-
139
- ## Why not just use transactions?
140
-
141
- Often you should. If the whole job is read a row, decide, write it back, and
142
- answer the request, then a transaction with `SELECT ... FOR UPDATE` does that
143
- and you need nothing else installed. In the browser, `navigator.locks` is the
144
- same answer. Reach for those first.
145
-
146
- The argument for an actor is scope, not discipline. A lock is scoped to one
147
- transaction, on one connection, in one process. The ticket sale above leaves
148
- that scope on one line: the hold expires in ten minutes, and no transaction
149
- stays open for ten minutes. A `setTimeout` does not cover it either, because it
150
- dies with the process.
151
-
152
- Any column named `expiresAt`, `scheduledAt`, or `nextRunAt` is evidence that
153
- the critical section already outlived the lock that was supposed to cover it.
154
- What follows such a column is a sweeper that looks for due rows, and then a
155
- race between that sweeper and the next writer of the same row. The column, the
156
- sweeper, and the race are what a Solid Objects actor replaces.
157
-
158
- Three cases a lock cannot reach:
159
-
160
- - work that fires at a future moment, when no transaction of yours is open;
161
- - work that must survive a process restart, which rules out an in-process
162
- timer; and
163
- - a fan-in whose critical section spans many jobs over minutes, such as an
164
- import that counts its own chunks as each one finishes.
165
-
166
- If it all happens inside one request, use a lock. If something has to happen
167
- later, or has to survive a restart, that is when this is worth installing.
168
-
169
- ## Is it worth installing here?
170
-
171
- Worth it when several requests, jobs, or processes act on the same cart, room,
172
- device, event, or session, and each next action needs the last committed state.
173
- Worth it when that same thing also owns work that fires later, or a number a
174
- live page must show: per-document reminders and realtime projections of
175
- committed state are the same argument.
176
-
177
- Not worth it for a plain counter, a single-row update inside one transaction, a
178
- stateless job, bulk ingestion or a data-parallel pipeline, CPU-heavy work, a
179
- large JSON document that belongs in normalized rows, globally placed edge
180
- state, or a global rate-limit counter that every request touches. One hot
181
- identity is serialized on purpose, so making everything one identity makes a
182
- queue. Split an identity only when the domain can tolerate independent
183
- ordering and transactions.
184
-
185
- High-QPS reads and hot identities are where this runtime stops being the right
186
- tool on its own. [Solid Objects Pro](https://solidobjects.pro/) is a commercial
187
- performance layer for the family that adds grouped commits, which coalesce
188
- concurrent writes into fewer database commits; optional ephemeral operations,
189
- which take loss-tolerant calls out of the durable journal; and materialized
190
- projections, which build read models after commit so reads stop competing with
191
- mailbox work. It ships for the Rails gem today, and the Node build is in
192
- development.
193
-
194
- [What Solid Objects is for](#what-solid-objects-is-for) has the pattern table,
195
- and [Choosing Solid Objects](docs/fit.md) is the longer guide.
196
-
197
- ## Run it now with SQLite
198
-
199
- The published package includes a quickstart that needs no checkout, database
200
- server, container, or configuration:
117
+ Run the background roles in one terminal and place two concurrent holds in
118
+ another:
201
119
 
202
120
  ```bash
203
- npm exec --yes --package=solid-objects@latest -- solid-objects quickstart
121
+ node ticket-sale.mjs work
122
+ node ticket-sale.mjs hold
204
123
  ```
205
124
 
206
- It states its plan, prints the `Counter` class it runs, and asks before doing
207
- anything. It asserts rather than printing a plausible result, and exits
208
- non-zero when a check fails. Add `--yes` to skip the question, or `--json` for
209
- a machine-readable summary.
210
-
211
- ## What Solid Objects is for
212
-
213
- Use Solid Objects when more than one request, job, or process can act on the
214
- same logical thing. The next action must then use the latest committed state of
215
- that thing.
216
- These are the stateful coordination patterns for which people often reach for
217
- Durable Objects:
218
-
219
- | Pattern | One identity per | What the object coordinates |
220
- | --------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------- |
221
- | Multiplayer, presence, or collaboration | Room, session, or document | Joins, moves, and edits commit in order; subscribers refresh from committed state |
222
- | Reservations and expiring holds | Show, resource, or stock item | Availability checks and holds cannot interleave; a durable reminder can release an old hold |
223
- | Checkout and account workflows | Cart, order, account, device | The current step, retries, and effect results return to the same ordered mailbox |
224
- | Per-key rate limits | API key, account, or device | Token checks and decrements are serialized; a reminder can refill the bucket |
225
- | Stateful agent sessions | Agent session | Messages and tool results apply in order and pending work survives a worker exit |
226
-
227
- The common shape is one durable coordination boundary with an application
228
- defined identity. Work for that identity is serialized, while unrelated rooms,
229
- carts, accounts, or sessions can progress concurrently. A single global rate
230
- limiter or another very hot identity is a poor fit because it becomes an
231
- intentional bottleneck. If one ordinary row transaction solves the problem,
232
- prefer that. See [Choosing Solid Objects](docs/fit.md) for the longer guide.
233
-
234
- ## Measured behavior
235
-
236
- One developer machine, not a capacity promise: Apple M5, Node.js 24.18.0, 250
237
- operations at client concurrency 16, on August 22, 2026.
238
-
239
- | Measurement | Result |
240
- | ------------------------------------------------------------- | ---------------: |
241
- | Committed operations per second, one hot identity, SQLite | 286 to 323 ops/s |
242
- | The same identity across four processes, SQLite | 507 to 519 ops/s |
243
- | Idle wake-up to committed result, one process | 2.66 ms p50 |
244
- | Idle wake-up to committed result, two processes, polling only | 1,006 ms p50 |
245
-
246
- Calls to one identity are serialized on purpose, so per-call latency includes
247
- the wait behind the other fifteen callers. That last row is the tradeoff to
248
- know before deploying: use PostgreSQL notifications or the optional Redis
249
- Pub/Sub when separate processes need low-latency delivery. The same databases
250
- in Docker Desktop reached 1.8x to 4.9x less throughput.
251
-
252
- PostgreSQL and MySQL numbers, idle CPU, sources of bias, and the full matrix
253
- are in [Benchmarks](docs/benchmarks.md). Measure your own deployment shape
254
- before planning capacity.
255
-
256
- ## Running in a deployed application
257
-
258
- [Shuffle Up and Play](https://shuffleupandplay.com/) is a deployed reference
259
- application. Two players create a table, load decks, and move cards. Realtime
260
- updates reach both browsers. Its
261
- [source](https://github.com/cardmagic/shuffleupandplay) uses Node 24,
262
- TypeScript, SQLite, `node:http`, and `ws`. Each table code addresses one
263
- `GameRoom` actor that owns both seats, so mutations share one durable mailbox
264
- while each player receives a separately authorized projection.
265
-
266
- The application and its tests exercise more than a counter-shaped happy path:
267
-
268
- | Production concern | Verifiable application evidence |
269
- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
270
- | Concurrent mutations | One [`GameRoom`](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/actors/game-room.ts#L45-L125) owns a table. [Mailbox tests](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/durability.test.ts#L39-L94) submit concurrent life, draw, and shuffle operations and assert the final committed state. |
271
- | Controlled restarts | [Restart tests](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/restart.test.ts#L38-L136) close and reopen the runtime against the same SQLite file, then assert recovery of committed state, an accepted asynchronous operation, an unfinished effect, and a scheduled reminder. |
272
- | Persistent deployment | The [runtime uses SQLite](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/runtime.ts#L45-L63); the [container runs as an unprivileged user](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/Dockerfile#L20-L37), and [Kamal mounts a persistent volume](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/config/deploy.yml#L28-L43). |
273
- | Private realtime state | [Subscription policy](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/runtime.ts#L65-L82) and [per-seat projection](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/game/room-snapshot.ts#L92-L130) run on the server. [HTTP](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/server.test.ts#L526-L567) and [WebSocket tests](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/realtime.test.ts#L184-L235) assert that opponent card identities are absent from player payloads and shared invalidation envelopes. |
274
- | External work | Deck imports run as [durable effects with success and failure callbacks](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/actors/game-room.ts#L192-L253). [Tests](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/game-room.test.ts#L209-L331) cover both outcomes and prevent a superseded callback from replacing a newer deck result. |
275
- | Transactional staged work | A room operation stages an [actor-to-actor log message](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/actors/game-room.ts#L339-L349) and a [database commit action](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/runtime.ts#L96-L113). Tests cover [rollback of staged messages](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/durability.test.ts#L160-L192) and the [metrics write](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/server.test.ts#L467-L499). |
276
- | Time and schema changes | The actor defines [versioned state migrations](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/actors/game-room.ts#L47-L91) and a [durable reminder](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/src/actors/game-room.ts#L276-L310). Tests load [stored version-one state](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/operations.test.ts#L135-L201) and [run the reminder scheduler](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/durability.test.ts#L236-L257). |
277
- | Operations and CI | The [operations tests](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/operations.test.ts#L39-L202) exercise doctor, process, retention, and reconciliation APIs; server suites cover the [dashboard](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/server.test.ts#L297-L326), [rate limits](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/rate-limit.test.ts), and [shutdown](https://github.com/cardmagic/shuffleupandplay/blob/519a343e8db0bb6eed961a2ffd374dba80d67cd6/test/shutdown.test.ts). The [current main CI run](https://github.com/cardmagic/shuffleupandplay/actions/runs/31963000789) passed typechecking, 171 tests, the build, the doctor, and a Docker image build. |
278
-
279
- **Scope:** the checked-in deployment configuration runs one Node process with
280
- SQLite on one Docker host. It shows a real deployed workload. It does not show a
281
- measured traffic level or every supported topology. Its deck-import effect reads
282
- an external API. An effect that writes to an external system still needs a
283
- stable idempotency key, because delivery is at least once. The restart tests
284
- close the runtime cleanly. The library verifies abrupt termination, PostgreSQL,
285
- MySQL, and multi-process lease fencing separately in its
286
- [test matrix](docs/support.md),
287
- [failure-recovery demonstration](examples/failure-recovery/demo.ts), and
288
- [correctness contract](docs/correctness.md). Compare those guarantees and limits
289
- with your own workload.
290
-
291
- ## How it works
292
-
293
- An actor is addressed by its class and an application-chosen ID. Fields are
294
- durable state, methods are operations, and getters are queries. One operation
295
- is one turn: the runtime claims the actor under a fenced lease, runs your
296
- JavaScript outside the transaction, then commits state, results, staged
297
- effects, reminders, and realtime invalidations together. A failure retries with
298
- backoff and dead-letters at the limit.
299
-
300
- The fence includes the activation owner, token, generation, expiration, and
301
- claimed message, so a worker that finishes its JavaScript after losing the
302
- lease cannot commit. The database is the source of truth. Redis is optional
303
- acceleration, and polling remains the recovery path.
304
-
305
- [`examples/failure-recovery/demo.ts`](examples/failure-recovery/demo.ts) kills a
306
- worker mid-turn and shows the survivor finish it.
307
- [Architecture](docs/architecture.md) and
308
- [Operations](docs/operations.md) cover the turn lifecycle, polling backoff, and
309
- wake-up adapters.
310
-
311
- ## Delivery boundaries
312
-
313
- - Operations are ordered per identity and execute **at least once**.
314
- - Fencing protects the database commit. It cannot undo an HTTP request, email,
315
- payment, or file write, so external systems need the stable effect ID or
316
- another durable idempotency key.
317
- - Different identities run concurrently; one hot identity cannot.
318
- - Cross-object transactions are not provided.
319
- - Realtime sessions are process-local, so a multi-process application must
320
- bridge committed broadcast events to the processes holding live connections.
321
-
322
- [Correctness and delivery semantics](docs/correctness.md) has the complete
323
- contract, including `stateVersion` compatibility and the database write guard,
324
- and [Errors and recovery](docs/errors-and-recovery.md) covers failure handling.
325
-
326
- ## Realtime committed state
327
-
328
- For a like count or a dashboard number, write the row and then send on your own
329
- socket. That is less code than this library and it works.
330
-
331
- It gets harder when several people write to the same record at once. Each
332
- request builds its payload in its own process and sends it. The lock decided
333
- who wrote first, but it has no say over which of the two sends arrives last, so
334
- a viewer can be left looking at the older number. The second gap is that the
335
- send is not part of the write: if the process dies after the database commits
336
- and before the send goes out, the tab keeps a wrong number and nothing corrects
337
- it.
338
-
339
- An observable is the alternative. The change and its publication commit
340
- together, so no crash can leave one without the other. A worker delivers the
341
- publication afterwards, claiming rows in actor revision order, and subscribers
342
- reject a duplicate or stale revision. Delivery is still at least once, so the
343
- guarantee is that a subscriber cannot end up on an older value, not that a
344
- value is sent exactly once.
345
-
346
- Actors opt into browser-visible dependencies. In `0.13`, an unwrapped
347
- observable triggers invalidation without storing or sending its value. Use
348
- `broadcastValue()` only for a scalar that every authorized subscriber may see:
349
-
350
- ```typescript
351
- import { Actor, broadcastValue } from "solid-objects"
352
-
353
- class Room extends Actor {
354
- static override readonly actorType = "Room"
355
-
356
- version = 0
357
- privateHands: Record<string, string[]> = {}
358
-
359
- override observables(): Record<string, unknown> {
360
- return {
361
- version: broadcastValue(this.version),
362
- hands: this.privateHands,
363
- }
364
- }
365
- }
366
- ```
125
+ Only one buyer gets the ticket. The successful call stores the hold and its
126
+ ten-minute expiry together. Stop the worker process before the deadline and
127
+ restart it afterwards; the reminder is still in `tickets.sqlite3` and runs when
128
+ the process returns. We have now given `available += 1` a recovery plan.
129
+
130
+ The authorization callbacks above are for this local example only. Production
131
+ policies must bind actor IDs and operations to the authenticated user or tenant.
367
132
 
368
- `version` crosses the shared invalidation channel. `hands` contributes only its
369
- name when its real value changes. A reauthorized component endpoint can then
370
- render subscriber-specific state without a manual revision counter.
133
+ ## Why this exists
371
134
 
372
- The browser package handles replay, reconnection, incarnation/revision fences,
373
- personalized payloads, and framework-neutral component refresh. Applications
374
- provide authentication, WebSocket transport, and rendering. See the
375
- [browser protocol](docs/browser-protocol.md) and [authorization guide](docs/authorization.md).
135
+ The handwritten version usually starts with a row lock. Then it gains an
136
+ `expiresAt` column, a sweeper, retries, per-room ordering, and a broadcast path
137
+ that must agree with the write. The original transaction has developed a robust
138
+ interplay with four other subsystems.
139
+
140
+ Solid Objects makes the application-defined identity the coordination boundary.
141
+ Its state, mailbox, retries, reminders, and staged consequences live in SQLite,
142
+ PostgreSQL, or MySQL. No daemon, broker, Cloudflare account, or new datastore is
143
+ required. Redis is optional wake-up plumbing, not durable state.
144
+
145
+ ## Good uses
146
+
147
+ - Multiplayer rooms, collaborative sessions, and documents with ordered edits.
148
+ - Carts, reservations, and inventory holds with durable expiry.
149
+ - Accounts, devices, and long-lived jobs whose next action depends on committed state.
150
+ - Realtime multi-user state where publications must follow committed revisions.
151
+
152
+ Different identities can run concurrently. One global identity is merely a
153
+ queue wearing an ambitious name.
376
154
 
377
155
  ## Solid Objects in the browser
378
156
 
@@ -410,11 +188,12 @@ holder per origin through the Web Locks API, carries the other tabs' SQL to it
410
188
  over a `BroadcastChannel`, and fails over onto the same durable state when the
411
189
  holder's tab dies. Use `sqliteWasm` for a single dedicated worker.
412
190
 
413
- Two companions complete the local-first story: `solid-objects/browser/tab-host`
414
- runs one runtime for all tabs when the application prefers request-level
415
- routing, and `solid-objects/transmit` drains the transactional effects outbox
416
- to a server with at-least-once delivery, per-actor order, and an idempotent
417
- server ingest, so offline writes reconcile when the network returns.
191
+ Two more modules support local-first applications.
192
+ `solid-objects/browser/tab-host` runs one runtime for all tabs when the
193
+ application prefers request-level routing. `solid-objects/transmit` drains the
194
+ transactional effects outbox to a server with at-least-once delivery, per-actor
195
+ order, and an idempotent server ingest, so offline writes reconcile when the
196
+ network returns.
418
197
 
419
198
  ### The backend can be Rails, not only Node
420
199
 
@@ -447,99 +226,48 @@ The wire shapes are documented in the
447
226
  [public API reference](docs/api.md), and the platform boundaries in
448
227
  [supported versions](docs/support.md).
449
228
 
450
- ## Comparison
451
-
452
- A SQL transaction or row lock serializes selected rows for one transaction and
453
- needs nothing installed. A job queue retries a job but leaves ordering to queue
454
- configuration. Solid Objects serializes by object ID, keeps state in the
455
- database you already run, and adds a library rather than a service. Cloudflare
456
- Durable Objects, Rivet, DBOS, and Restate each add a managed runtime or server.
457
-
458
- celld and Solid Objects both self-host the Durable Objects model, and the
459
- difference is what you run. celld runs a daemon that embeds V8 and executes
460
- Wrangler bundles, gives each object its own SQLite database, replicates that
461
- database to an object-storage bucket you own, and moves ownership between nodes
462
- through compare-and-swap on that bucket. Solid Objects runs plain TypeScript
463
- classes inside your Node processes, adds no daemon, and keeps object state in
464
- the SQL database the application already operates. Choose celld to run
465
- Workers-format code across a fleet with bucket-based placement. Choose Solid
466
- Objects to keep one database, no extra process, and an ordinary Node
467
- deployment.
468
-
469
- [docs/comparisons.md](docs/comparisons.md) has the full table across eight
470
- systems, with primary sources for every dimension: realtime projections, edge
471
- placement, cross-identity transactions, and operational data access.
472
-
473
- ## Requirements and supported systems
474
-
475
- Node.js 24.4.0 or newer, TypeScript 5.9 or newer, and SQLite through
476
- `node:sqlite`, PostgreSQL 14 or newer, or MySQL 8.0 or newer on InnoDB.
477
- `pg`, `mysql2`, `ioredis`, and `@sqlite.org/sqlite-wasm` are optional peer
478
- dependencies, installed only for the adapters you use.
479
-
480
- [Supported versions](docs/support.md) records the exact CI matrix, the browser
481
- requirements for OPFS and the Web Locks API, and the boundaries.
482
-
483
- ## Operations
484
-
485
- `runtime.run(signal)` supervises the actor, effect, reminder, broadcast,
486
- retention, and stale-process recovery roles. An optional `solid-objects/web`
487
- export mounts a database-backed operator dashboard behind a deny-by-default
488
- administration policy, with session-backed CSRF protection and Fetch or
489
- Node/Connect mounting. Administration is also available through the JSON CLI
490
- and typed runtime managers.
491
-
492
- The dashboard has an explicitly public read-only mode. Use it only for
493
- synthetic demo data: it exposes stored arguments, results, errors, identifiers,
494
- and operational metadata. See [Operations](docs/operations.md), the
495
- [dashboard guide](docs/dashboard.md), and
496
- [Configuration](docs/configuration.md).
497
-
498
- ## Design provenance
499
-
500
- Solid Objects JS is a Node.js and TypeScript implementation. The Ruby
501
- [`solid_objects`](https://github.com/cardmagic/solid-objects-ruby) design informed
502
- it. It began at the `0.12` capability generation, because the first
503
- implementation targeted the Ruby `0.12` contract. That number does not represent
504
- twelve earlier JavaScript release generations.
505
-
506
- The TypeScript implementation is not a source translation. It redesigned the
507
- API around inferred TypeScript references, Node runtime supervision,
508
- `node:sqlite`/`pg`/`mysql2` adapters, transport-neutral realtime sessions, a
509
- framework-neutral component registry, and browser-safe package exports. The
510
- [parity ledger](docs/parity.md) records capability relationships and deliberate
511
- runtime differences.
512
-
513
- The Ruby project first appeared publicly on August 6, 2026, and this TypeScript
514
- repository on August 13, 2026. Both remain early releases. The
515
- [`mtg-playmat`](https://github.com/cardmagic/mtg-playmat) application uses the
516
- Ruby actor and realtime design.
517
- [Shuffle Up and Play](https://github.com/cardmagic/shuffleupandplay) uses the
518
- TypeScript package in the deployed Node and SQLite topology above.
519
-
520
- ## Documentation
521
-
522
- - [Getting the architecture right](docs/architecture.md)
523
- - [Correctness and delivery semantics](docs/correctness.md)
229
+ ## When a transaction is better
230
+
231
+ Often. If all the work happens in one request, use a transaction, constraint, or
232
+ `SELECT ... FOR UPDATE`. It is smaller, faster, and does not need a manifesto.
233
+
234
+ Use Solid Objects when the critical section outlives that transaction: work
235
+ must happen later, survive a restart, or remain ordered across several requests
236
+ or jobs. A plain counter is not a reason to install this package.
237
+
238
+ ## Guarantees and boundaries
239
+
240
+ - Calls are durably ordered per identity. Different identities may run concurrently.
241
+ - Delivery is **at least once**, not exactly once. A handler can begin again after a crash.
242
+ - One successful turn commits state and staged reminders, messages, effects, and realtime publications together.
243
+ - Fencing prevents a stale worker from committing, though its JavaScript may keep running. It cannot undo an HTTP request, email, payment, or file write.
244
+ - External effects can repeat and must use the stable effect ID or another durable idempotency key.
245
+ - One hot identity is intentionally sequential. There are no transactions across identities.
246
+ - Background work needs `runtime.run(signal)`. If no process is running, committed work waits in SQL rather than disappearing.
247
+ - Your application still owns authorization, database backups, failover, WebSocket transport, and capacity planning.
248
+
249
+ Exactly once remains absent, despite its excellent branding. Read the
250
+ [correctness contract](docs/correctness.md) before using important data.
251
+
252
+ ## Read more
253
+
254
+ - [Five-minute Node guide](https://solidobjects.dev/5min/node)
524
255
  - [Choosing Solid Objects](docs/fit.md)
525
- - [Benchmarks and methodology](docs/benchmarks.md)
526
- - [Supported versions and test matrix](docs/support.md)
527
- - [Test suite](https://github.com/cardmagic/solid-objects-js/tree/main/test)
528
- - [CI workflow](https://github.com/cardmagic/solid-objects-js/actions/workflows/ci.yml)
529
256
  - [Public API](docs/api.md)
530
- - [State and lifecycle](docs/state-and-lifecycle.md)
531
- - [Operations, retention, and reconciliation](docs/operations.md)
532
- - [Configuration](docs/configuration.md)
533
- - [Authorization](docs/authorization.md)
534
- - [Browser protocol](docs/browser-protocol.md)
535
- - [Operator dashboard](docs/dashboard.md)
536
- - [Errors and recovery](docs/errors-and-recovery.md)
537
- - [Design parity](docs/parity.md)
538
- - [Changelog](CHANGELOG.md)
539
- - [Releases](https://github.com/cardmagic/solid-objects-js/releases)
540
- - [Contributing](CONTRIBUTING.md)
541
- - [Security policy](SECURITY.md)
542
-
543
- ## License
544
-
545
- Solid Objects is released under the [MIT License](MIT-LICENSE).
257
+ - [Operations and recovery](docs/operations.md)
258
+ - [Detailed architecture](docs/architecture.md)
259
+ - [Detailed documentation](docs/)
260
+
261
+ The benchmarks, parity ledger, dashboard, browser setup, and exhaustive API
262
+ notes remain in `docs/`, where long documentation can be long on purpose.
263
+
264
+ ## Status and license
265
+
266
+ Solid Objects JS is a pre-1.0 early release. The correctness core has
267
+ automated coverage across the supported databases, browser runtime, recovery
268
+ paths, and packaged artifact. There is one deployed first-party reference
269
+ application, no measured scale, and no known third-party production use yet.
270
+ Pre-1.0 is doing actual work in that sentence.
271
+
272
+ Solid Objects is released under the [MIT License](MIT-LICENSE). It is an
273
+ independent project and is not affiliated with or endorsed by Cloudflare.