solid-objects 0.14.1 → 0.14.3

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,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.3 - 2026-08-25
4
+
5
+ - Rewrite the first screen around the objection a reader actually has. The
6
+ README led with a shopping cart that appended to an array, which invites the
7
+ reply that one SQL statement already does it. It now leads with the ticket
8
+ sale from the homepage: 100 seats, a hold, a ten-minute expiry that frees the
9
+ seat, and a published count. That is the smallest example needing three
10
+ things from one number, and the three things are the argument.
11
+ - Answer "why not just use transactions?" in the first screen instead of
12
+ burying the fit sections. The section concedes the transaction and the row
13
+ lock first, including `navigator.locks` in the browser, then argues scope
14
+ rather than discipline: any `expiresAt` or `scheduledAt` column is evidence
15
+ the critical section already outlived the lock, and what follows it is a
16
+ sweeper and a race.
17
+ - Add "Is it worth installing here?", which names who should not install this,
18
+ and point readers with high-QPS reads or hot identities at
19
+ [Solid Objects Pro](https://solidobjects.pro/).
20
+ - Correct two claims. The realtime section said a value is published once per
21
+ change from the saving turn; the turn records the publication atomically,
22
+ while delivery is a separate worker and is at least once. Design provenance
23
+ said the API was redesigned around Web Components; there is no
24
+ `customElements` or `HTMLElement` in the package.
25
+ - Cut the README from 590 to about 540 lines by removing what `docs/` already
26
+ documented and what the page said three times, and add the table of contents
27
+ the standard-readme specification asks for above 100 lines.
28
+
29
+ ## 0.14.2 - 2026-08-24
30
+
31
+ - State that background pickup needs `runtime.run(signal)`
32
+ ([#22](https://github.com/cardmagic/solid-objects-js/issues/22)). The
33
+ README's programming-model example works without it because the caller's
34
+ own path executes the call, and nothing on that page said that a process
35
+ which installs and then waits claims nothing. An external prober built a
36
+ two-process harness from the README and read the unclaimed messages as
37
+ stranded. The README and `docs/operations.md` now state it, and
38
+ `test/background-pickup.test.ts` pins it: a sent message reads `ready`
39
+ after `install()`, and `completed` once `run(signal)` starts the roles.
40
+ - Build the same example with `configure()` and address the actor as
41
+ `Cart.ref("cart-123")`, matching every other reference example in the
42
+ documentation. `createRuntime()` deliberately leaves the process default
43
+ unset, so the static form needs `configure()`.
44
+ - Add `examples/at-least-once` and `pnpm run test:at-least-once`
45
+ ([#23](https://github.com/cardmagic/solid-objects-js/issues/23)): an
46
+ executable proof that the at-least-once clause fires and that the
47
+ documented remedy absorbs it. An effect worker crashes between the
48
+ external sink write and the acknowledgement; after restart the sink
49
+ reads 2 with deduplication off, and 1 when a guard on the stable
50
+ effect id is in place. The state commit happens exactly once in both
51
+ runs, and both deliveries carry the same effect id. CI runs the demo
52
+ alongside the recovery demo.
53
+
3
54
  ## 0.14.1 - 2026-08-23
4
55
 
5
56
  - Add `solid-objects/signals`, live signals on actor references
package/README.md CHANGED
@@ -32,29 +32,69 @@ contract. See [Solid Objects in the browser](#solid-objects-in-the-browser).
32
32
  > data.
33
33
 
34
34
  > **Not a replacement for SQL transactions:** when one row update inside one
35
- > transaction solves the problem, use that. Solid Objects earns its cost when an
36
- > entity needs ordered calls across requests, retries, reminders, effects, and
37
- > realtime state. See [Good and poor fits](#good-and-poor-fits).
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).
40
+
41
+ ## Contents
42
+
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)
53
+ - [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)
38
60
 
39
61
  ## The programming model
40
62
 
63
+ A ticket sale for one event, with 100 seats and a hold that expires:
64
+
41
65
  ```typescript
42
- import { Actor, createRuntime } from "solid-objects"
66
+ import { Actor, broadcastValue, configure } from "solid-objects"
43
67
  import { sqlite } from "solid-objects/database/sqlite"
44
68
 
45
- class Cart extends Actor {
46
- static override readonly actorType = "Cart"
69
+ class TicketSale extends Actor {
70
+ static override readonly actorType = "TicketSale"
71
+
72
+ remaining = 100
73
+ holds: Record<string, number> = {}
74
+
75
+ override observables(): Record<string, unknown> {
76
+ return { remaining: broadcastValue(this.remaining) }
77
+ }
47
78
 
48
- items: string[] = []
79
+ reserve({ buyer }: { buyer: string }): boolean {
80
+ if (this.remaining === 0 || buyer in this.holds) return false
81
+ this.remaining -= 1
82
+ this.holds = { ...this.holds, [buyer]: Date.now() }
83
+ this.schedule({ at: new Date(Date.now() + 600_000), key: buyer }).expire!({ buyer })
84
+ return true
85
+ }
49
86
 
50
- add({ sku }: { sku: string }): number {
51
- this.items.push(sku)
52
- return this.items.length
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
53
93
  }
54
94
  }
55
95
 
56
- const runtime = createRuntime({
57
- database: sqlite({ path: "cart.sqlite3" }),
96
+ const runtime = configure({
97
+ database: sqlite({ path: "tickets.sqlite3" }),
58
98
  authorizeMessage: () => true,
59
99
  authorizeQuery: () => true,
60
100
  })
@@ -62,39 +102,111 @@ const runtime = createRuntime({
62
102
  await runtime.install()
63
103
 
64
104
  try {
65
- const cart = runtime.ref(Cart, "cart-123")
66
- await Promise.all([cart.add({ sku: "blue-shirt" }), cart.add({ sku: "green-hat" })])
105
+ const sale = TicketSale.ref("event-42")
106
+ const buyers = ["ada", "grace", "alan"]
107
+ await Promise.all(buyers.map((buyer) => sale.reserve({ buyer })))
67
108
  } finally {
68
109
  await runtime.close()
69
110
  }
70
111
  ```
71
112
 
72
- Both calls enter the durable mailbox for `cart-123`. They execute in order and
73
- commit one state transition at a time, even when different requests or Node.js
74
- processes submit them concurrently.
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.
75
196
 
76
197
  ## Run it now with SQLite
77
198
 
78
- Node.js 24.4.0 or newer is required. Node.js 24.15 or newer is preferred,
79
- because `node:sqlite` prints an experimental warning before it. The published
80
- package includes a quickstart:
199
+ The published package includes a quickstart that needs no checkout, database
200
+ server, container, or configuration:
81
201
 
82
202
  ```bash
83
203
  npm exec --yes --package=solid-objects@latest -- solid-objects quickstart
84
204
  ```
85
205
 
86
- The command needs no repository checkout, database server, Redis, container, or
87
- application configuration. It uses Node's built-in SQLite module and removes
88
- its scoped temporary database before exiting.
89
-
90
- It states its plan first, prints the `Counter` class it runs, and asks for
91
- permission. It executes the work only after you answer, and then it explains
92
- what each result proves. It asks nothing when stdin is not a terminal, so CI
93
- never waits. Add `--yes` to skip the question in a terminal, or `--json` for a
94
- machine-readable summary.
95
-
96
- The executable asserts rather than merely printing a plausible result. It exits
97
- with a non-zero code when one of those checks fails.
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.
98
210
 
99
211
  ## What Solid Objects is for
100
212
 
@@ -121,38 +233,25 @@ prefer that. See [Choosing Solid Objects](docs/fit.md) for the longer guide.
121
233
 
122
234
  ## Measured behavior
123
235
 
124
- One developer machine, not a capacity promise. Apple M5, Node.js 24.18.0, 250
125
- measured operations at client concurrency 16, on August 22, 2026. PostgreSQL
126
- 17.11 and MySQL 9.7.1 run natively, not in a container.
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.
127
238
 
128
239
  | Measurement | Result |
129
240
  | ------------------------------------------------------------- | ---------------: |
130
241
  | Committed operations per second, one hot identity, SQLite | 286 to 323 ops/s |
131
242
  | The same identity across four processes, SQLite | 507 to 519 ops/s |
132
- | The same identity across four processes, PostgreSQL | 266 to 331 ops/s |
133
- | The same identity across four processes, MySQL | 214 to 228 ops/s |
134
243
  | Idle wake-up to committed result, one process | 2.66 ms p50 |
135
244
  | Idle wake-up to committed result, two processes, polling only | 1,006 ms p50 |
136
- | Idle CPU per process, 100 ms fast interval | 0.121% |
137
- | Idle database passes per second, after backoff | 4.0 |
138
245
 
139
- The four idle rows come from a separate harness on August 16, 2026.
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.
140
251
 
141
- Each range spans the synchronous and the asynchronous handler shape. Calls to
142
- one identity are serialized on purpose, so the per-call latency in these runs
143
- includes the wait behind the other fifteen concurrent callers. Throughput is
144
- the honest number for that case.
145
-
146
- The same PostgreSQL and MySQL versions in Docker Desktop reached 1.8x to 4.9x
147
- less throughput on those rows. Measure your own deployment shape before you
148
- plan capacity.
149
-
150
- The polling-only row is the tradeoff to know before you deploy: use PostgreSQL
151
- notifications or the optional Redis Pub/Sub when separate processes need
152
- low-latency delivery.
153
-
154
- Conditions, sources of bias, and the complete matrix for all three databases
155
- are in [Benchmarks](docs/benchmarks.md).
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.
156
255
 
157
256
  ## Running in a deployed application
158
257
 
@@ -191,74 +290,59 @@ with your own workload.
191
290
 
192
291
  ## How it works
193
292
 
194
- Solid Objects addresses an object by its TypeScript class and its
195
- application-defined ID. Public fields are JSON state, public methods are durable
196
- operations, and public getters are ordered queries.
197
-
198
- For each identity, Solid Objects:
199
-
200
- 1. commits calls to a durable per-ID mailbox;
201
- 2. claims one activation with a renewable lease;
202
- 3. executes one operation at a time outside the database transaction;
203
- 4. commits state, completion, and staged work in a short fenced transaction;
204
- 5. retries recoverable failures and exposes terminal failures as dead letters;
205
- 6. publishes committed realtime invalidations in revision order.
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.
206
299
 
207
300
  The fence includes the activation owner, token, generation, expiration, and
208
- claimed message. A worker that finishes JavaScript after losing its lease
209
- cannot commit. See the executable [failure-recovery demonstration](examples/failure-recovery/demo.ts)
210
- and the full [architecture](docs/architecture.md).
211
-
212
- Redis is optional wake-up infrastructure. It can reduce notification latency in
213
- a multi-process MySQL deployment. The relational database stays the durable
214
- source of truth, and polling stays the recovery path.
215
-
216
- Idle roles back off from the configured 100 ms fast polling interval to one
217
- second. Processed work and wake-up notifications reset that interval
218
- immediately. The default wake-up reaches only the current Node process; use the
219
- PostgreSQL or optional Redis adapter when separate processes need low-latency
220
- delivery. The runtime warns once when it sees that topology without an adapter.
221
-
222
- ## Good and poor fits
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.
223
304
 
224
- | Good fit | Poor fit |
225
- | --------------------------------------------------------- | --------------------------------------------------------- |
226
- | Multiplayer rooms and collaborative sessions | A single-row update already solved by one SQL transaction |
227
- | Shopping carts, accounts, devices, and per-user workflows | Bulk ingestion and data-parallel pipelines |
228
- | Stateful agent sessions with ordered tool results | Very high-throughput global counters |
229
- | Per-document or per-device reminders | Large JSON documents that should remain normalized rows |
230
- | Realtime projections of committed state | Globally placed edge state or managed elastic placement |
231
-
232
- One hot identity is intentionally serialized. Split an identity only when the
233
- domain can tolerate independent ordering and transactions. Solid Objects does
234
- not provide a transaction across object identities.
235
-
236
- The longer decision guide is in [Choosing Solid Objects](docs/fit.md).
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.
237
310
 
238
311
  ## Delivery boundaries
239
312
 
240
313
  - Operations are ordered per identity and execute **at least once**.
241
- - A crash after arbitrary external I/O but before the database commit can cause
242
- that I/O to repeat. Use the stable effect ID or another durable idempotency
243
- key at the external system.
244
- - Fencing protects the Solid Objects database commit. It cannot undo an HTTP
245
- request, email, payment, file write, or other external side effect.
246
- - Different identities can execute concurrently; one hot identity cannot.
247
- - State, result, actor-to-actor delivery, reminders, effects, commit actions,
248
- and realtime invalidations commit together for one operation.
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.
249
318
  - Cross-object transactions are not provided.
250
- - Application processes with incompatible `stateVersion` values must not run
251
- together. Older code rejects state written by a newer version.
252
- - Direct application-database writes are guarded only when the application
253
- uses the supplied database facade. Unwrapped clients cannot be intercepted.
254
- - Realtime sessions are process-local. A multi-process application must bridge
255
- committed broadcast events to the processes holding live connections.
319
+ - Realtime sessions are process-local, so a multi-process application must
320
+ bridge committed broadcast events to the processes holding live connections.
256
321
 
257
- See [Correctness and delivery semantics](docs/correctness.md) and
258
- [Errors and recovery](docs/errors-and-recovery.md) for the complete contract.
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.
259
325
 
260
326
  ## Realtime committed state
261
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
+
262
346
  Actors opt into browser-visible dependencies. In `0.13`, an unwrapped
263
347
  observable triggers invalidation without storing or sending its value. Use
264
348
  `broadcastValue()` only for a scalar that every authorized subscriber may see:
@@ -321,33 +405,27 @@ await runtime.install()
321
405
  await Counter.ref("page-hits").increment()
322
406
  ```
323
407
 
324
- That code runs identically in every tab. `sharedSqliteWasm` elects one
325
- database holder per origin through the Web Locks API, carries the other
326
- tabs' SQL to it over a `BroadcastChannel`, and fails over onto the same
327
- durable state when the holder's tab dies. Use `sqliteWasm` directly for a
328
- single dedicated worker.
329
-
330
- Two companions complete the local-first story:
408
+ That code runs identically in every tab. `sharedSqliteWasm` elects one database
409
+ holder per origin through the Web Locks API, carries the other tabs' SQL to it
410
+ over a `BroadcastChannel`, and fails over onto the same durable state when the
411
+ holder's tab dies. Use `sqliteWasm` for a single dedicated worker.
331
412
 
332
- - `solid-objects/browser/tab-host` runs one runtime for all tabs when the
333
- application prefers request-level routing: the leader's worker executes
334
- every operation, and other tabs invoke through a `BroadcastChannel` client
335
- by name.
336
- - `solid-objects/transmit` drains the transactional effects outbox to a
337
- server with at-least-once delivery, per-actor order, and an idempotent
338
- server ingest, so offline writes reconcile when the network returns.
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.
339
418
 
340
419
  ### The backend can be Rails, not only Node
341
420
 
342
- The browser runtime does not require a Node server behind it. The transmit
343
- wire contract is shared with the Ruby gem
421
+ The browser runtime does not require a Node server behind it. The transmit wire
422
+ contract is shared with the Ruby gem
344
423
  ([solid-objects-ruby](https://github.com/cardmagic/solid-objects-ruby)):
345
424
  `SolidObjects::Transmission.receive` accepts the same envelopes as the Node
346
- ingest `receiveTransmitEnvelope`, dedups on the same `transmit:<effectId>`
347
- key, and both repositories pin the contract with one shared fixture file.
348
- A browser front end on `solid-objects/browser/host` inside a Rails
349
- application therefore replays its offline writes directly onto Ruby server
350
- actors — no Node service in between:
425
+ ingest `receiveTransmitEnvelope`, dedups on the same `transmit:<effectId>` key,
426
+ and both repositories pin the contract with one shared fixture file. A browser
427
+ front end therefore replays its offline writes directly onto Ruby server
428
+ actors, with no Node service in between:
351
429
 
352
430
  ```ruby
353
431
  class TransmitController < ApplicationController
@@ -371,65 +449,51 @@ The wire shapes are documented in the
371
449
 
372
450
  ## Comparison
373
451
 
374
- These systems solve different coordination problems. The table describes their
375
- default unit and deployment model, not a quality ranking.
376
-
377
- | Approach | Serialization and state unit | Durable substrate | Additional runtime | Recovery model | Placement |
378
- | --------------------------- | ----------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------- |
379
- | SQL transaction or row lock | Selected rows in one transaction | Application database | None | Application retries the transaction | Application deployment |
380
- | Traditional job queue | Job or queue; ordering depends on queue configuration | Broker or queue database | Queue workers and usually a broker | Retry the job | Application deployment |
381
- | Solid Objects | TypeScript class plus object ID | Existing SQLite, PostgreSQL, or MySQL | Library in application processes | Retry the per-ID operation from durable state | Application deployment |
382
- | Cloudflare Durable Objects | Object class plus globally unique ID | Per-object managed storage | Cloudflare Workers platform | Managed object activation | Cloudflare-selected location |
383
- | celld | Object class plus object name | Per-object SQLite replicated to a bucket you own | celld daemon that embeds V8 and runs Wrangler bundles | A new owner restores the object database from the bucket | Any node in your fleet, chosen by bucket compare-and-swap |
384
- | Rivet Actors | Addressable actor | Actor state, KV, or per-actor SQLite | Rivet Engine or managed compute | Actor sleep, wake, and persistence | Configured Rivet deployment |
385
- | DBOS | Workflow ID and checkpointed steps | PostgreSQL system database | Library; Conductor recommended for distributed recovery | Deterministic workflow replay from checkpoints | Application deployment |
386
- | Restate | Service handler or keyed virtual object | Restate log and state store | Restate server or cloud service | Durable handler execution and journal replay | Restate deployment |
387
-
388
- celld and Solid Objects both self-host the Durable Objects model. The difference
389
- is where the state lives and what you run. celld runs a daemon that embeds V8
390
- and executes Wrangler bundles. It gives each object its own SQLite database,
391
- and it replicates that database to an object-storage bucket you own. Object
392
- ownership moves between nodes through compare-and-swap on that bucket. Solid
393
- Objects runs plain TypeScript classes inside your Node processes, adds no
394
- daemon, and keeps object state in the SQL database the application already
395
- operates. Choose celld to run Workers-format code across a fleet with
396
- bucket-based placement. Choose Solid Objects to keep one database, no extra
397
- process, and an ordinary Node deployment.
398
-
399
- [docs/comparisons.md](docs/comparisons.md) holds the sourced comparison for each
400
- dimension: realtime projections, edge placement, cross-identity transactions,
401
- and operational data access.
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.
402
472
 
403
473
  ## Requirements and supported systems
404
474
 
405
- - Node.js 24.4.0 or newer; 24.15 or newer to avoid the `node:sqlite`
406
- experimental warning
407
- - TypeScript 5.9 or newer for TypeScript applications
408
- - SQLite through `node:sqlite`, PostgreSQL 14 or newer, or MySQL 8.0 or newer
409
- with InnoDB
410
- - optional `pg`, `mysql2`, `redis`, or `@sqlite.org/sqlite-wasm` peer
411
- dependency only for the selected adapter
412
- - for the browser runtime: a browser with OPFS for persistent storage and the
413
- Web Locks API for the multi-tab host
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.
414
479
 
415
- [Supported versions](docs/support.md) records the exact CI matrix and the
416
- boundaries.
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.
417
482
 
418
483
  ## Operations
419
484
 
420
- `runtime.run(signal)` supervises actor, effect, reminder, broadcast, retention,
421
- and stale-process recovery roles. The database-backed operator dashboard is an
422
- optional `solid-objects/web` export with deny-by-default administration policy,
423
- session-backed CSRF protection, and Fetch or Node/Connect mounting.
424
-
425
- The dashboard defaults to authorized read/write access. An authorized read-only
426
- mode removes the mutations. Use the explicitly public read-only mode only for
427
- synthetic demo data, because it exposes stored arguments, results, errors,
428
- identifiers, and operational metadata.
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.
429
491
 
430
- Administration remains available through the JSON CLI and typed runtime
431
- managers. See [Operations](docs/operations.md), the [dashboard guide](docs/dashboard.md),
432
- and [Configuration](docs/configuration.md).
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).
433
497
 
434
498
  ## Design provenance
435
499
 
@@ -441,8 +505,8 @@ twelve earlier JavaScript release generations.
441
505
 
442
506
  The TypeScript implementation is not a source translation. It redesigned the
443
507
  API around inferred TypeScript references, Node runtime supervision,
444
- `node:sqlite`/`pg`/`mysql2` adapters, transport-neutral realtime sessions,
445
- Web Components, and browser-safe package exports. The
508
+ `node:sqlite`/`pg`/`mysql2` adapters, transport-neutral realtime sessions, a
509
+ framework-neutral component registry, and browser-safe package exports. The
446
510
  [parity ledger](docs/parity.md) records capability relationships and deliberate
447
511
  runtime differences.
448
512
 
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.14.1";
1
+ export declare const VERSION = "0.14.3";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = "0.14.1";
1
+ export const VERSION = "0.14.3";
2
2
  //# sourceMappingURL=version.js.map
@@ -58,7 +58,12 @@
58
58
 
59
59
  - At-least-once execution means actor code may begin more than once. State and
60
60
  staged intents from a failed turn roll back, but arbitrary external work does
61
- not. External systems need stable idempotency keys.
61
+ not. External systems need stable idempotency keys. This clause is
62
+ observable, not decorative: `pnpm run test:at-least-once` crashes an
63
+ effect worker between the sink write and the acknowledgement, restarts
64
+ it, and shows the sink reading 2 with deduplication off — then shows a
65
+ guard on the stable effect id absorbing the same duplicate, with the
66
+ sink reading 1. The state commit happens exactly once in both runs.
62
67
  - The activation fence protects the Solid Objects commit. It cannot revoke or
63
68
  undo network calls, files, emails, payments, or other external effects.
64
69
  - One identity processes one write operation at a time. This is the ordering
@@ -1,5 +1,12 @@
1
1
  # Operations
2
2
 
3
+ `install()` prepares the database and starts nothing. A process serves
4
+ background work only after `runtime.run(signal)` starts its roles. A process
5
+ that registers actors, installs, and then waits never claims a ready message,
6
+ and work enqueued with `send` stays ready until some process runs the roles.
7
+ A direct call or an explicit `sync` needs no running role, because the caller's
8
+ own path executes it.
9
+
3
10
  Runtime roles use durable polling as the correctness fallback. Consecutive
4
11
  empty passes double each role's wait from `pollingIntervalMilliseconds` to
5
12
  `idlePollingIntervalMilliseconds`, which defaults to one second. Processed
@@ -0,0 +1,13 @@
1
+ import { Actor } from "solid-objects"
2
+
3
+ export class DeliveryCounter extends Actor {
4
+ static override readonly actorType = "DeliveryCounter"
5
+
6
+ count = 0
7
+
8
+ deliver(): number {
9
+ this.count += 1
10
+ this.emit("record", { arguments: {} })
11
+ return this.count
12
+ }
13
+ }
@@ -0,0 +1,129 @@
1
+ import assert from "node:assert/strict"
2
+ import { fork, type ChildProcess } from "node:child_process"
3
+ import { mkdtemp, rm } from "node:fs/promises"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { fileURLToPath } from "node:url"
7
+ import { createRuntime } from "solid-objects"
8
+ import { sqlite } from "solid-objects/database/sqlite"
9
+ import { DeliveryCounter } from "./actor.ts"
10
+ import { readSink } from "./sink.ts"
11
+
12
+ const directory = await mkdtemp(join(tmpdir(), "solid-objects-at-least-once-"))
13
+ const databasePath = join(directory, "state.sqlite3")
14
+ const runtime = createRuntime({
15
+ database: sqlite({ path: databasePath, timeoutMilliseconds: 2_000, lockRetryAttempts: 20 }),
16
+ leaseDurationMilliseconds: 250,
17
+ leaseRenewalIntervalMilliseconds: 50,
18
+ processHeartbeatIntervalMilliseconds: 75,
19
+ processAliveThresholdMilliseconds: 300,
20
+ workerCount: 1,
21
+ effectWorkerCount: 0,
22
+ reminderSchedulerCount: 0,
23
+ retentionIntervalMilliseconds: 0,
24
+ deadProcessCleanupIntervalMilliseconds: 0,
25
+ authorizeMessage: () => true,
26
+ authorizeQuery: () => true,
27
+ authorizeAdministration: () => true,
28
+ })
29
+
30
+ try {
31
+ runtime.register(DeliveryCounter)
32
+ await runtime.install()
33
+ const duplicate = await proveDuplicateAtSink()
34
+ const remedy = await proveDeduplicationAbsorbsIt()
35
+ process.stdout.write(`${JSON.stringify({ duplicate, remedy }, null, 2)}\n`)
36
+ } finally {
37
+ await runtime.close()
38
+ await rm(directory, { recursive: true })
39
+ }
40
+
41
+ async function proveDuplicateAtSink(): Promise<{
42
+ stateCommits: number
43
+ sinkDeliveries: number
44
+ sameEffectId: boolean
45
+ attempts: number[]
46
+ }> {
47
+ const sinkPath = join(directory, "sink-dedup-off.json")
48
+ await stageOneDelivery("dedup-off")
49
+ await crashThenRecover({ sinkPath, deduplicate: "off" })
50
+
51
+ const sink = await readSink(sinkPath)
52
+ const snapshot = await runtime.ref(DeliveryCounter, "dedup-off").snapshot()
53
+ assert.equal(snapshot.count, 1, "the state commit happened exactly once")
54
+ assert.equal(sink.deliveries.length, 2, "the sink observed the duplicate")
55
+ assert.equal(
56
+ sink.deliveries[0]?.effectId,
57
+ sink.deliveries[1]?.effectId,
58
+ "both deliveries carried the same stable effect id",
59
+ )
60
+ return {
61
+ stateCommits: snapshot.count,
62
+ sinkDeliveries: sink.deliveries.length,
63
+ sameEffectId: sink.deliveries[0]?.effectId === sink.deliveries[1]?.effectId,
64
+ attempts: sink.deliveries.map((delivery) => delivery.attempt),
65
+ }
66
+ }
67
+
68
+ async function proveDeduplicationAbsorbsIt(): Promise<{
69
+ stateCommits: number
70
+ sinkDeliveries: number
71
+ }> {
72
+ const sinkPath = join(directory, "sink-dedup-on.json")
73
+ await stageOneDelivery("dedup-on")
74
+ await crashThenRecover({ sinkPath, deduplicate: "on" })
75
+
76
+ const sink = await readSink(sinkPath)
77
+ const snapshot = await runtime.ref(DeliveryCounter, "dedup-on").snapshot()
78
+ assert.equal(snapshot.count, 1, "the state commit happened exactly once")
79
+ assert.equal(sink.deliveries.length, 1, "the stable effect id absorbed the duplicate")
80
+ return { stateCommits: snapshot.count, sinkDeliveries: sink.deliveries.length }
81
+ }
82
+
83
+ async function stageOneDelivery(actorId: string): Promise<void> {
84
+ const message = await runtime.ref(DeliveryCounter, actorId).send.deliver()
85
+ const worker = runtime.worker()
86
+ try {
87
+ let processed = 0
88
+ for (let attempt = 0; attempt < 200 && processed === 0; attempt += 1) {
89
+ processed = await worker.runOnce({ activationRetention: "release" })
90
+ if (processed === 0) await new Promise((resolve) => setTimeout(resolve, 10))
91
+ }
92
+ assert.equal(processed, 1, "the actor turn committed and staged the effect")
93
+ } finally {
94
+ await worker.stop()
95
+ }
96
+ assert.equal(await message.status(), "completed")
97
+ }
98
+
99
+ async function crashThenRecover(options: {
100
+ sinkPath: string
101
+ deduplicate: "on" | "off"
102
+ }): Promise<void> {
103
+ const crashing = spawnEffectWorker({ ...options, mode: "crash" })
104
+ const crashExit = await crashing.finished
105
+ assert.equal(crashExit, 1, "the first delivery crashed before acknowledgement")
106
+
107
+ await new Promise((resolve) => setTimeout(resolve, 400))
108
+
109
+ const recovering = spawnEffectWorker({ ...options, mode: "complete" })
110
+ const recoveryExit = await recovering.finished
111
+ assert.equal(recoveryExit, 0, "the second delivery completed and acknowledged")
112
+ }
113
+
114
+ function spawnEffectWorker(options: {
115
+ sinkPath: string
116
+ deduplicate: "on" | "off"
117
+ mode: "crash" | "complete"
118
+ }): { child: ChildProcess; finished: Promise<number | null> } {
119
+ const child = fork(
120
+ fileURLToPath(new URL("./effect-worker.ts", import.meta.url)),
121
+ [databasePath, options.sinkPath, options.mode, options.deduplicate],
122
+ { stdio: ["ignore", "inherit", "inherit", "ipc"] },
123
+ )
124
+ const finished = new Promise<number | null>((resolve, reject) => {
125
+ child.once("exit", (code) => resolve(code))
126
+ child.once("error", reject)
127
+ })
128
+ return { child, finished }
129
+ }
@@ -0,0 +1,62 @@
1
+ import { createRuntime } from "solid-objects"
2
+ import { sqlite } from "solid-objects/database/sqlite"
3
+ import { DeliveryCounter } from "./actor.ts"
4
+ import { recordDelivery } from "./sink.ts"
5
+
6
+ const databasePath = requiredArgument(2)
7
+ const sinkPath = requiredArgument(3)
8
+ const mode = requiredArgument(4)
9
+ const deduplicate = requiredArgument(5) === "on"
10
+
11
+ const runtime = createRuntime({
12
+ database: sqlite({ path: databasePath, timeoutMilliseconds: 2_000, lockRetryAttempts: 20 }),
13
+ pollingIntervalMilliseconds: 10,
14
+ leaseDurationMilliseconds: 250,
15
+ leaseRenewalIntervalMilliseconds: 50,
16
+ processHeartbeatIntervalMilliseconds: 75,
17
+ processAliveThresholdMilliseconds: 300,
18
+ workerCount: 0,
19
+ effectWorkerCount: 1,
20
+ reminderSchedulerCount: 0,
21
+ retentionIntervalMilliseconds: 0,
22
+ deadProcessCleanupIntervalMilliseconds: 0,
23
+ authorizeMessage: () => true,
24
+ authorizeQuery: () => true,
25
+ authorizeAdministration: () => true,
26
+ })
27
+
28
+ runtime.register(DeliveryCounter)
29
+ runtime.registerEffect("record", async (_argumentsValue, context) => {
30
+ const { applied } = await recordDelivery({
31
+ path: sinkPath,
32
+ effectId: context.id,
33
+ attempt: context.attempt,
34
+ deduplicate,
35
+ })
36
+ process.send?.({ event: "sink.recorded", effectId: context.id, applied })
37
+ if (mode === "crash") {
38
+ process.exit(1)
39
+ }
40
+ return null
41
+ })
42
+ await runtime.install()
43
+ const effectWorker = runtime.effectWorker()
44
+
45
+ try {
46
+ let processed = 0
47
+ for (let attempt = 0; attempt < 200 && processed === 0; attempt += 1) {
48
+ processed = await effectWorker.runOnce()
49
+ if (processed === 0) await new Promise((resolve) => setTimeout(resolve, 10))
50
+ }
51
+ if (processed === 0) throw new Error("no effect became claimable")
52
+ process.send?.({ event: "effects.finished", processed })
53
+ } finally {
54
+ await effectWorker.stop()
55
+ await runtime.close()
56
+ }
57
+
58
+ function requiredArgument(index: number): string {
59
+ const value = process.argv[index]
60
+ if (!value) throw new TypeError(`argument ${index - 1} is required`)
61
+ return value
62
+ }
@@ -0,0 +1,34 @@
1
+ import { readFile, writeFile } from "node:fs/promises"
2
+
3
+ export interface SinkDelivery {
4
+ effectId: string
5
+ attempt: number
6
+ }
7
+
8
+ export interface SinkState {
9
+ deliveries: SinkDelivery[]
10
+ }
11
+
12
+ export async function readSink(path: string): Promise<SinkState> {
13
+ try {
14
+ const parsed: SinkState = JSON.parse(await readFile(path, "utf-8"))
15
+ return parsed
16
+ } catch (error) {
17
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { deliveries: [] }
18
+ throw error
19
+ }
20
+ }
21
+
22
+ export async function recordDelivery(options: {
23
+ path: string
24
+ effectId: string
25
+ attempt: number
26
+ deduplicate: boolean
27
+ }): Promise<{ applied: boolean }> {
28
+ const sink = await readSink(options.path)
29
+ const seen = sink.deliveries.some((delivery) => delivery.effectId === options.effectId)
30
+ if (options.deduplicate && seen) return { applied: false }
31
+ sink.deliveries.push({ effectId: options.effectId, attempt: options.attempt })
32
+ await writeFile(options.path, JSON.stringify(sink, null, 2))
33
+ return { applied: true }
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid-objects",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
4
4
  "description": "Race-free realtime state per application identity, backed by your SQL database",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -107,6 +107,7 @@
107
107
  "test:mysql": "vitest run test/mysql.test.ts",
108
108
  "test:package": "node scripts/release-artifact-smoke.mjs",
109
109
  "test:recovery": "pnpm run build && node examples/failure-recovery/demo.ts",
110
+ "test:at-least-once": "pnpm run build && node examples/at-least-once/demo.ts",
110
111
  "test:redis": "vitest run test/redis-wake-up.test.ts",
111
112
  "test:watch": "vitest",
112
113
  "benchmark": "pnpm run build && node benchmarks/run.ts",