From 4cd4d39e9868bccab09ad5fafeb8dae842efd184 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 25 Aug 2026 10:53:18 -0700 Subject: [PATCH 1/7] docs: lead with the case a lock cannot cover Two comments on r/rails, both negative, and the first one was right: a counter is the worst possible lead example, because one statement of SQL does it. The README opened with a shopping cart that pushed a string onto an array, which reads the same way. The first thing a skeptical reader saw was the argument against installing anything. The lead is now the ticket sale from the homepage: 100 seats, a hold, a ten-minute expiry that frees the seat, and a published count. It is the smallest example that needs three things from one number, and the three things are the actual argument. A cart append needs one. The objection now gets answered where it is asked. A new section concedes the transaction and the row lock first, including navigator.locks in the browser, then makes the case on scope rather than discipline: a lock is scoped to one transaction, on one connection, in one process, and any expiresAt or scheduledAt column is evidence the critical section already outlived it. What follows that column is a sweeper, and then a race between the sweeper and the next writer. Arguing that someone might forget the lock would lose, because the reader answers "so remember". The transactions callout already existed and asserted the limit without showing where it bites, so it now points at that section. A short worth-it and not-worth-it section moves into the first screen, and the realtime section concedes send-on-your-own-socket before explaining where push-after-write drops an update. --- README.md | 126 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 109 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d557c5d..194352a 100644 --- a/README.md +++ b/README.md @@ -32,29 +32,49 @@ contract. See [Solid Objects in the browser](#solid-objects-in-the-browser). > data. > **Not a replacement for SQL transactions:** when one row update inside one -> transaction solves the problem, use that. Solid Objects earns its cost when an -> entity needs ordered calls across requests, retries, reminders, effects, and -> realtime state. See [Good and poor fits](#good-and-poor-fits). +> transaction solves the problem, use that and install nothing. Solid Objects +> earns its cost when the critical section outlives the transaction: a hold that +> expires in ten minutes, work that must survive a restart, or a fan-in that +> spans many jobs. See +> [Why not just use a transaction and a row lock?](#why-not-just-use-a-transaction-and-a-row-lock). ## The programming model +A ticket sale for one event, with 100 seats and a hold that expires: + ```typescript -import { Actor, configure } from "solid-objects" +import { Actor, broadcastValue, configure } from "solid-objects" import { sqlite } from "solid-objects/database/sqlite" -class Cart extends Actor { - static override readonly actorType = "Cart" +class TicketSale extends Actor { + static override readonly actorType = "TicketSale" + + remaining = 100 + holds: Record = {} + + override observables(): Record { + return { remaining: broadcastValue(this.remaining) } + } - items: string[] = [] + reserve({ buyer }: { buyer: string }): boolean { + if (this.remaining === 0 || buyer in this.holds) return false + this.remaining -= 1 + this.holds = { ...this.holds, [buyer]: Date.now() } + this.schedule({ at: new Date(Date.now() + 600_000), key: buyer }).expire!({ buyer }) + return true + } - add({ sku }: { sku: string }): number { - this.items.push(sku) - return this.items.length + expire({ buyer }: { buyer: string }): void { + if (!(buyer in this.holds)) return + const rest = { ...this.holds } + delete rest[buyer] + this.holds = rest + this.remaining += 1 } } const runtime = configure({ - database: sqlite({ path: "cart.sqlite3" }), + database: sqlite({ path: "tickets.sqlite3" }), authorizeMessage: () => true, authorizeQuery: () => true, }) @@ -62,16 +82,27 @@ const runtime = configure({ await runtime.install() try { - const cart = Cart.ref("cart-123") - await Promise.all([cart.add({ sku: "blue-shirt" }), cart.add({ sku: "green-hat" })]) + const sale = TicketSale.ref("event-42") + const buyers = ["ada", "grace", "alan"] + await Promise.all(buyers.map((buyer) => sale.reserve({ buyer }))) } finally { await runtime.close() } ``` -Both calls enter the durable mailbox for `cart-123`. They execute in order and -commit one state transition at a time, even when different requests or Node.js -processes submit them concurrently. +Every reserve enters the durable mailbox for `event-42`. They execute in order +and commit one state transition at a time, even when different requests or +Node.js processes submit them concurrently, so the guard on `remaining` cannot +oversell. + +That example wants three things from the same number. It must never go below +zero. It must give the seat back if the buyer does not pay within ten minutes. +It must show the current count to everyone watching the page. + +The first is one UPDATE statement. The second is an `expiresAt` column plus a +sweeper. The third is a push on every code path that changes the number. The +combination is what costs, not any one of them. Here the guard, the ten-minute +alarm, and the published count are one class, and they commit together. `install()` prepares the database and starts nothing. The example above finishes because the caller's own path executes each call. A process serves background @@ -85,6 +116,53 @@ process.on("SIGTERM", () => controller.abort()) await runtime.run(controller.signal) ``` +## Why not just use a transaction and a row lock? + +Often you should. If the whole job is read a row, decide, write it back, and +answer the request, then a transaction with `SELECT ... FOR UPDATE` does that +and you need nothing else installed. In the browser, `navigator.locks` is the +same answer. Reach for those first. + +The argument for an actor is scope, not discipline. A lock is scoped to one +transaction, on one connection, in one process. The ticket sale above leaves +that scope on one line: the hold expires in ten minutes, and no transaction +stays open for ten minutes. A `setTimeout` does not cover it either, because it +dies with the process. + +Any column named `expiresAt`, `scheduledAt`, or `nextRunAt` is evidence that +the critical section already outlived the lock that was supposed to cover it. +What follows such a column is a sweeper that looks for due rows, and then a +race between that sweeper and the next writer of the same row. The column, the +sweeper, and the race are what an actor replaces. + +Three cases a lock cannot reach: + +- work that fires at a future moment, when no transaction of yours is open; +- work that must survive a process restart, which rules out an in-process + timer; and +- a fan-in whose critical section spans many jobs over minutes, such as an + import that counts its own chunks as each one finishes. + +If it all happens inside one request, use a lock. If something has to happen +later, or has to survive a restart, that is when this is worth installing. + +## Is it worth installing here? + +Worth it when several requests, jobs, or processes act on the same cart, room, +device, event, or session, and each next action needs the last committed state. +Worth it when that same thing also owns work that fires later, or a number a +live page must show. + +Not worth it for a plain counter, a single-row update inside one transaction, a +stateless job, bulk ingestion or a data-parallel pipeline, CPU-heavy work, a +large JSON document that belongs in normalized rows, or a global rate-limit +counter that every request touches. One hot identity is serialized on purpose, +so making everything one identity makes a queue. + +The longer version is in [What Solid Objects is for](#what-solid-objects-is-for), +[Good and poor fits](#good-and-poor-fits), and +[Choosing Solid Objects](docs/fit.md). + ## Run it now with SQLite Node.js 24.4.0 or newer is required. Node.js 24.15 or newer is preferred, @@ -271,6 +349,20 @@ See [Correctness and delivery semantics](docs/correctness.md) and ## Realtime committed state +For a like count or a dashboard number, write the row and then send on your own +socket. That is less code than this library and it works. + +It gets harder when several people write to the same record at once. Each +request builds its payload in its own process and sends it. The lock decided +who wrote first, but it has no say over which of the two sends arrives last, so +a viewer can be left looking at the older number. The second gap is that the +send is not part of the write: if the process dies after the database commits +and before the send goes out, the tab keeps a wrong number and nothing corrects +it. + +An observable is the alternative. The value is published once per change, in +commit order, from the same turn that saved the change. + Actors opt into browser-visible dependencies. In `0.13`, an unwrapped observable triggers invalidation without storing or sending its value. Use `broadcastValue()` only for a scalar that every authorized subscriber may see: @@ -359,7 +451,7 @@ ingest `receiveTransmitEnvelope`, dedups on the same `transmit:` key, and both repositories pin the contract with one shared fixture file. A browser front end on `solid-objects/browser/host` inside a Rails application therefore replays its offline writes directly onto Ruby server -actors — no Node service in between: +actors, with no Node service in between: ```ruby class TransmitController < ApplicationController From 365753d943b24e9bbcb537dc0cd7a3f45442bc44 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 25 Aug 2026 11:04:32 -0700 Subject: [PATCH 2/7] fix: separate recording a publication from delivering it The realtime paragraph claimed the value is published once per change from the saving turn. The saving turn records the publication in the same transaction as the state change, which is the part that answers push-after-write. Delivery is a separate worker and is at least once, so "published once" promised something the runtime does not. The claim now matches docs/correctness.md: rows are claimed in actor revision order, subscribers reject a duplicate or stale revision, and the guarantee is that a subscriber cannot end up on an older value. That distinction is what makes the argument against push-after-write work. The point was never that a socket send is unreliable. It is that the send is not part of the write and its ordering comes from arrival rather than revision. --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 194352a..f65bb9d 100644 --- a/README.md +++ b/README.md @@ -360,8 +360,12 @@ send is not part of the write: if the process dies after the database commits and before the send goes out, the tab keeps a wrong number and nothing corrects it. -An observable is the alternative. The value is published once per change, in -commit order, from the same turn that saved the change. +An observable is the alternative. The change and its publication commit +together, so no crash can leave one without the other. A worker delivers the +publication afterwards, claiming rows in actor revision order, and subscribers +reject a duplicate or stale revision. Delivery is still at least once, so the +guarantee is that a subscriber cannot end up on an older value, not that a +value is sent exactly once. Actors opt into browser-visible dependencies. In `0.13`, an unwrapped observable triggers invalidation without storing or sending its value. Use From e033f385101204d4d00f1275144e6c9b993c3a68 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 25 Aug 2026 11:19:03 -0700 Subject: [PATCH 3/7] docs: cut the README to what only it can say The framing pass added the argument the README was missing, but it added it to a page that already repeated itself. The single-row-transaction point appeared in five places. Three sections answered "is this for me" with overlapping tables. Length is not thoroughness: a reader who bounces at screen three never reaches the reference material that justified it. Removed what docs/ owns outright. Measured behavior restated docs/benchmarks.md number for number, so it keeps the four rows that change a decision and links the rest. How it works, Delivery boundaries, Requirements, and Operations restated correctness, support, and operations docs, so each keeps the claims a reader needs before clicking and drops the rest. The Comparison table restated docs/comparisons.md, so the celld paragraph stays, because it is the only place that says what to choose and why, and the table goes. Removed what the README said three times. "Good and poor fits" is gone, with its unique rows folded into "Is it worth installing here?": per document reminders, realtime projections, edge placement, and the identity-splitting rule. The pattern table survives because its "one identity per" column exists nowhere else. Fixed a claim that was not true. Design provenance said the API was redesigned around Web Components. There is no customElements or HTMLElement anywhere in src; the browser surface is a framework-neutral component registry. --- README.md | 275 ++++++++++++++++++++---------------------------------- 1 file changed, 101 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index f65bb9d..8259c35 100644 --- a/README.md +++ b/README.md @@ -151,40 +151,33 @@ later, or has to survive a restart, that is when this is worth installing. Worth it when several requests, jobs, or processes act on the same cart, room, device, event, or session, and each next action needs the last committed state. Worth it when that same thing also owns work that fires later, or a number a -live page must show. +live page must show: per-document reminders and realtime projections of +committed state are the same argument. Not worth it for a plain counter, a single-row update inside one transaction, a stateless job, bulk ingestion or a data-parallel pipeline, CPU-heavy work, a -large JSON document that belongs in normalized rows, or a global rate-limit -counter that every request touches. One hot identity is serialized on purpose, -so making everything one identity makes a queue. +large JSON document that belongs in normalized rows, globally placed edge +state, or a global rate-limit counter that every request touches. One hot +identity is serialized on purpose, so making everything one identity makes a +queue. Split an identity only when the domain can tolerate independent +ordering and transactions. -The longer version is in [What Solid Objects is for](#what-solid-objects-is-for), -[Good and poor fits](#good-and-poor-fits), and -[Choosing Solid Objects](docs/fit.md). +[What Solid Objects is for](#what-solid-objects-is-for) has the pattern table, +and [Choosing Solid Objects](docs/fit.md) is the longer guide. ## Run it now with SQLite -Node.js 24.4.0 or newer is required. Node.js 24.15 or newer is preferred, -because `node:sqlite` prints an experimental warning before it. The published -package includes a quickstart: +The published package includes a quickstart that needs no checkout, database +server, container, or configuration: ```bash npm exec --yes --package=solid-objects@latest -- solid-objects quickstart ``` -The command needs no repository checkout, database server, Redis, container, or -application configuration. It uses Node's built-in SQLite module and removes -its scoped temporary database before exiting. - -It states its plan first, prints the `Counter` class it runs, and asks for -permission. It executes the work only after you answer, and then it explains -what each result proves. It asks nothing when stdin is not a terminal, so CI -never waits. Add `--yes` to skip the question in a terminal, or `--json` for a -machine-readable summary. - -The executable asserts rather than merely printing a plausible result. It exits -with a non-zero code when one of those checks fails. +It states its plan, prints the `Counter` class it runs, and asks before doing +anything. It asserts rather than printing a plausible result, and exits +non-zero when a check fails. Add `--yes` to skip the question, or `--json` for +a machine-readable summary. ## What Solid Objects is for @@ -211,38 +204,25 @@ prefer that. See [Choosing Solid Objects](docs/fit.md) for the longer guide. ## Measured behavior -One developer machine, not a capacity promise. Apple M5, Node.js 24.18.0, 250 -measured operations at client concurrency 16, on August 22, 2026. PostgreSQL -17.11 and MySQL 9.7.1 run natively, not in a container. +One developer machine, not a capacity promise: Apple M5, Node.js 24.18.0, 250 +operations at client concurrency 16, on August 22, 2026. | Measurement | Result | | ------------------------------------------------------------- | ---------------: | | Committed operations per second, one hot identity, SQLite | 286 to 323 ops/s | | The same identity across four processes, SQLite | 507 to 519 ops/s | -| The same identity across four processes, PostgreSQL | 266 to 331 ops/s | -| The same identity across four processes, MySQL | 214 to 228 ops/s | | Idle wake-up to committed result, one process | 2.66 ms p50 | | Idle wake-up to committed result, two processes, polling only | 1,006 ms p50 | -| Idle CPU per process, 100 ms fast interval | 0.121% | -| Idle database passes per second, after backoff | 4.0 | - -The four idle rows come from a separate harness on August 16, 2026. - -Each range spans the synchronous and the asynchronous handler shape. Calls to -one identity are serialized on purpose, so the per-call latency in these runs -includes the wait behind the other fifteen concurrent callers. Throughput is -the honest number for that case. -The same PostgreSQL and MySQL versions in Docker Desktop reached 1.8x to 4.9x -less throughput on those rows. Measure your own deployment shape before you -plan capacity. +Calls to one identity are serialized on purpose, so per-call latency includes +the wait behind the other fifteen callers. That last row is the tradeoff to +know before deploying: use PostgreSQL notifications or the optional Redis +Pub/Sub when separate processes need low-latency delivery. The same databases +in Docker Desktop reached 1.8x to 4.9x less throughput. -The polling-only row is the tradeoff to know before you deploy: use PostgreSQL -notifications or the optional Redis Pub/Sub when separate processes need -low-latency delivery. - -Conditions, sources of bias, and the complete matrix for all three databases -are in [Benchmarks](docs/benchmarks.md). +PostgreSQL and MySQL numbers, idle CPU, sources of bias, and the full matrix +are in [Benchmarks](docs/benchmarks.md). Measure your own deployment shape +before planning capacity. ## Running in a deployed application @@ -281,71 +261,38 @@ with your own workload. ## How it works -Solid Objects addresses an object by its TypeScript class and its -application-defined ID. Public fields are JSON state, public methods are durable -operations, and public getters are ordered queries. - -For each identity, Solid Objects: - -1. commits calls to a durable per-ID mailbox; -2. claims one activation with a renewable lease; -3. executes one operation at a time outside the database transaction; -4. commits state, completion, and staged work in a short fenced transaction; -5. retries recoverable failures and exposes terminal failures as dead letters; -6. publishes committed realtime invalidations in revision order. +An actor is addressed by its class and an application-chosen ID. Fields are +durable state, methods are operations, and getters are queries. One operation +is one turn: the runtime claims the actor under a fenced lease, runs your +JavaScript outside the transaction, then commits state, results, staged +effects, reminders, and realtime invalidations together. A failure retries with +backoff and dead-letters at the limit. The fence includes the activation owner, token, generation, expiration, and -claimed message. A worker that finishes JavaScript after losing its lease -cannot commit. See the executable [failure-recovery demonstration](examples/failure-recovery/demo.ts) -and the full [architecture](docs/architecture.md). +claimed message, so a worker that finishes its JavaScript after losing the +lease cannot commit. The database is the source of truth. Redis is optional +acceleration, and polling remains the recovery path. -Redis is optional wake-up infrastructure. It can reduce notification latency in -a multi-process MySQL deployment. The relational database stays the durable -source of truth, and polling stays the recovery path. - -Idle roles back off from the configured 100 ms fast polling interval to one -second. Processed work and wake-up notifications reset that interval -immediately. The default wake-up reaches only the current Node process; use the -PostgreSQL or optional Redis adapter when separate processes need low-latency -delivery. The runtime warns once when it sees that topology without an adapter. - -## Good and poor fits - -| Good fit | Poor fit | -| --------------------------------------------------------- | --------------------------------------------------------- | -| Multiplayer rooms and collaborative sessions | A single-row update already solved by one SQL transaction | -| Shopping carts, accounts, devices, and per-user workflows | Bulk ingestion and data-parallel pipelines | -| Stateful agent sessions with ordered tool results | Very high-throughput global counters | -| Per-document or per-device reminders | Large JSON documents that should remain normalized rows | -| Realtime projections of committed state | Globally placed edge state or managed elastic placement | - -One hot identity is intentionally serialized. Split an identity only when the -domain can tolerate independent ordering and transactions. Solid Objects does -not provide a transaction across object identities. - -The longer decision guide is in [Choosing Solid Objects](docs/fit.md). +[`examples/failure-recovery/demo.ts`](examples/failure-recovery/demo.ts) kills a +worker mid-turn and shows the survivor finish it. +[Architecture](docs/architecture.md) and +[Operations](docs/operations.md) cover the turn lifecycle, polling backoff, and +wake-up adapters. ## Delivery boundaries - Operations are ordered per identity and execute **at least once**. -- A crash after arbitrary external I/O but before the database commit can cause - that I/O to repeat. Use the stable effect ID or another durable idempotency - key at the external system. -- Fencing protects the Solid Objects database commit. It cannot undo an HTTP - request, email, payment, file write, or other external side effect. -- Different identities can execute concurrently; one hot identity cannot. -- State, result, actor-to-actor delivery, reminders, effects, commit actions, - and realtime invalidations commit together for one operation. +- Fencing protects the database commit. It cannot undo an HTTP request, email, + payment, or file write, so external systems need the stable effect ID or + another durable idempotency key. +- Different identities run concurrently; one hot identity cannot. - Cross-object transactions are not provided. -- Application processes with incompatible `stateVersion` values must not run - together. Older code rejects state written by a newer version. -- Direct application-database writes are guarded only when the application - uses the supplied database facade. Unwrapped clients cannot be intercepted. -- Realtime sessions are process-local. A multi-process application must bridge - committed broadcast events to the processes holding live connections. +- Realtime sessions are process-local, so a multi-process application must + bridge committed broadcast events to the processes holding live connections. -See [Correctness and delivery semantics](docs/correctness.md) and -[Errors and recovery](docs/errors-and-recovery.md) for the complete contract. +[Correctness and delivery semantics](docs/correctness.md) has the complete +contract, including `stateVersion` compatibility and the database write guard, +and [Errors and recovery](docs/errors-and-recovery.md) covers failure handling. ## Realtime committed state @@ -429,32 +376,26 @@ await runtime.install() await Counter.ref("page-hits").increment() ``` -That code runs identically in every tab. `sharedSqliteWasm` elects one -database holder per origin through the Web Locks API, carries the other -tabs' SQL to it over a `BroadcastChannel`, and fails over onto the same -durable state when the holder's tab dies. Use `sqliteWasm` directly for a -single dedicated worker. - -Two companions complete the local-first story: +That code runs identically in every tab. `sharedSqliteWasm` elects one database +holder per origin through the Web Locks API, carries the other tabs' SQL to it +over a `BroadcastChannel`, and fails over onto the same durable state when the +holder's tab dies. Use `sqliteWasm` for a single dedicated worker. -- `solid-objects/browser/tab-host` runs one runtime for all tabs when the - application prefers request-level routing: the leader's worker executes - every operation, and other tabs invoke through a `BroadcastChannel` client - by name. -- `solid-objects/transmit` drains the transactional effects outbox to a - server with at-least-once delivery, per-actor order, and an idempotent - server ingest, so offline writes reconcile when the network returns. +Two companions complete the local-first story: `solid-objects/browser/tab-host` +runs one runtime for all tabs when the application prefers request-level +routing, and `solid-objects/transmit` drains the transactional effects outbox +to a server with at-least-once delivery, per-actor order, and an idempotent +server ingest, so offline writes reconcile when the network returns. ### The backend can be Rails, not only Node -The browser runtime does not require a Node server behind it. The transmit -wire contract is shared with the Ruby gem +The browser runtime does not require a Node server behind it. The transmit wire +contract is shared with the Ruby gem ([solid-objects-ruby](https://github.com/cardmagic/solid-objects-ruby)): `SolidObjects::Transmission.receive` accepts the same envelopes as the Node -ingest `receiveTransmitEnvelope`, dedups on the same `transmit:` -key, and both repositories pin the contract with one shared fixture file. -A browser front end on `solid-objects/browser/host` inside a Rails -application therefore replays its offline writes directly onto Ruby server +ingest `receiveTransmitEnvelope`, dedups on the same `transmit:` key, +and both repositories pin the contract with one shared fixture file. A browser +front end therefore replays its offline writes directly onto Ruby server actors, with no Node service in between: ```ruby @@ -479,65 +420,51 @@ The wire shapes are documented in the ## Comparison -These systems solve different coordination problems. The table describes their -default unit and deployment model, not a quality ranking. - -| Approach | Serialization and state unit | Durable substrate | Additional runtime | Recovery model | Placement | -| --------------------------- | ----------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------- | -| SQL transaction or row lock | Selected rows in one transaction | Application database | None | Application retries the transaction | Application deployment | -| 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 | -| 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 | -| Cloudflare Durable Objects | Object class plus globally unique ID | Per-object managed storage | Cloudflare Workers platform | Managed object activation | Cloudflare-selected location | -| 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 | -| Rivet Actors | Addressable actor | Actor state, KV, or per-actor SQLite | Rivet Engine or managed compute | Actor sleep, wake, and persistence | Configured Rivet deployment | -| DBOS | Workflow ID and checkpointed steps | PostgreSQL system database | Library; Conductor recommended for distributed recovery | Deterministic workflow replay from checkpoints | Application deployment | -| 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 | - -celld and Solid Objects both self-host the Durable Objects model. The difference -is where the state lives and what you run. celld runs a daemon that embeds V8 -and executes Wrangler bundles. It gives each object its own SQLite database, -and it replicates that database to an object-storage bucket you own. Object -ownership moves between nodes through compare-and-swap on that bucket. Solid -Objects runs plain TypeScript classes inside your Node processes, adds no -daemon, and keeps object state in the SQL database the application already -operates. Choose celld to run Workers-format code across a fleet with -bucket-based placement. Choose Solid Objects to keep one database, no extra -process, and an ordinary Node deployment. - -[docs/comparisons.md](docs/comparisons.md) holds the sourced comparison for each -dimension: realtime projections, edge placement, cross-identity transactions, -and operational data access. +A SQL transaction or row lock serializes selected rows for one transaction and +needs nothing installed. A job queue retries a job but leaves ordering to queue +configuration. Solid Objects serializes by object ID, keeps state in the +database you already run, and adds a library rather than a service. Cloudflare +Durable Objects, Rivet, DBOS, and Restate each add a managed runtime or server. + +celld and Solid Objects both self-host the Durable Objects model, and the +difference is what you run. celld runs a daemon that embeds V8 and executes +Wrangler bundles, gives each object its own SQLite database, replicates that +database to an object-storage bucket you own, and moves ownership between nodes +through compare-and-swap on that bucket. Solid Objects runs plain TypeScript +classes inside your Node processes, adds no daemon, and keeps object state in +the SQL database the application already operates. Choose celld to run +Workers-format code across a fleet with bucket-based placement. Choose Solid +Objects to keep one database, no extra process, and an ordinary Node +deployment. + +[docs/comparisons.md](docs/comparisons.md) has the full table across eight +systems, with primary sources for every dimension: realtime projections, edge +placement, cross-identity transactions, and operational data access. ## Requirements and supported systems -- Node.js 24.4.0 or newer; 24.15 or newer to avoid the `node:sqlite` - experimental warning -- TypeScript 5.9 or newer for TypeScript applications -- SQLite through `node:sqlite`, PostgreSQL 14 or newer, or MySQL 8.0 or newer - with InnoDB -- optional `pg`, `mysql2`, `redis`, or `@sqlite.org/sqlite-wasm` peer - dependency only for the selected adapter -- for the browser runtime: a browser with OPFS for persistent storage and the - Web Locks API for the multi-tab host +Node.js 24.4.0 or newer, TypeScript 5.9 or newer, and SQLite through +`node:sqlite`, PostgreSQL 14 or newer, or MySQL 8.0 or newer on InnoDB. +`pg`, `mysql2`, `ioredis`, and `@sqlite.org/sqlite-wasm` are optional peer +dependencies, installed only for the adapters you use. -[Supported versions](docs/support.md) records the exact CI matrix and the -boundaries. +[Supported versions](docs/support.md) records the exact CI matrix, the browser +requirements for OPFS and the Web Locks API, and the boundaries. ## Operations -`runtime.run(signal)` supervises actor, effect, reminder, broadcast, retention, -and stale-process recovery roles. The database-backed operator dashboard is an -optional `solid-objects/web` export with deny-by-default administration policy, -session-backed CSRF protection, and Fetch or Node/Connect mounting. - -The dashboard defaults to authorized read/write access. An authorized read-only -mode removes the mutations. Use the explicitly public read-only mode only for -synthetic demo data, because it exposes stored arguments, results, errors, -identifiers, and operational metadata. +`runtime.run(signal)` supervises the actor, effect, reminder, broadcast, +retention, and stale-process recovery roles. An optional `solid-objects/web` +export mounts a database-backed operator dashboard behind a deny-by-default +administration policy, with session-backed CSRF protection and Fetch or +Node/Connect mounting. Administration is also available through the JSON CLI +and typed runtime managers. -Administration remains available through the JSON CLI and typed runtime -managers. See [Operations](docs/operations.md), the [dashboard guide](docs/dashboard.md), -and [Configuration](docs/configuration.md). +The dashboard has an explicitly public read-only mode. Use it only for +synthetic demo data: it exposes stored arguments, results, errors, identifiers, +and operational metadata. See [Operations](docs/operations.md), the +[dashboard guide](docs/dashboard.md), and +[Configuration](docs/configuration.md). ## Design provenance @@ -549,8 +476,8 @@ twelve earlier JavaScript release generations. The TypeScript implementation is not a source translation. It redesigned the API around inferred TypeScript references, Node runtime supervision, -`node:sqlite`/`pg`/`mysql2` adapters, transport-neutral realtime sessions, -Web Components, and browser-safe package exports. The +`node:sqlite`/`pg`/`mysql2` adapters, transport-neutral realtime sessions, a +framework-neutral component registry, and browser-safe package exports. The [parity ledger](docs/parity.md) records capability relationships and deliberate runtime differences. From fdab8af38012723d321390a207bd6a1d05c8b532 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 25 Aug 2026 12:08:48 -0700 Subject: [PATCH 4/7] docs: add the table of contents the spec asks for The standard-readme spec requires a table of contents above 100 lines. This file is 537 with 17 sections and never had one, so a reader landing from the npm page had no map of what the page covers. An audit against that spec, the Prana et al. content categories, and the popularity correlation study found nothing else missing here: this README already carries contribution and security links, uses lists, and links out to docs. The Ruby sibling had the larger gaps. --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 8259c35..0d7a9de 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,26 @@ contract. See [Solid Objects in the browser](#solid-objects-in-the-browser). > spans many jobs. See > [Why not just use a transaction and a row lock?](#why-not-just-use-a-transaction-and-a-row-lock). +## Contents + +- [The programming model](#the-programming-model) +- [Why not just use a transaction and a row lock?](#why-not-just-use-a-transaction-and-a-row-lock) +- [Is it worth installing here?](#is-it-worth-installing-here) +- [Run it now with SQLite](#run-it-now-with-sqlite) +- [What Solid Objects is for](#what-solid-objects-is-for) +- [Measured behavior](#measured-behavior) +- [Running in a deployed application](#running-in-a-deployed-application) +- [How it works](#how-it-works) +- [Delivery boundaries](#delivery-boundaries) +- [Realtime committed state](#realtime-committed-state) +- [Solid Objects in the browser](#solid-objects-in-the-browser) +- [Comparison](#comparison) +- [Requirements and supported systems](#requirements-and-supported-systems) +- [Operations](#operations) +- [Design provenance](#design-provenance) +- [Documentation](#documentation) +- [License](#license) + ## The programming model A ticket sale for one event, with 100 seats and a hold that expires: From 7bdc549eaf233666a5f699b41685b936a5dcffb1 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 25 Aug 2026 12:15:19 -0700 Subject: [PATCH 5/7] docs: name the objection and point at the Pro layer The section answering the lock objection was titled after the mechanism, "a transaction and a row lock", which is longer than the question a reader actually asks. The heading now matches the Ruby sibling and says transactions. The body still names SELECT ... FOR UPDATE and navigator.locks, because those are what a reader reaches for. "What an actor replaces" was ambiguous in a document that also discusses Durable Objects, Rivet actors, and celld. It now says a Solid Objects actor. The not-worth-it list ended at hot identities and global counters without saying where such a reader should go. Solid Objects Pro is the commercial performance layer for the family, so the list now names it and what it adds. It says the Rails gem ships today and the Node build is in development, which is what the product page states, so a reader here does not expect something they cannot buy yet. --- README.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0d7a9de..8df68c3 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,12 @@ contract. See [Solid Objects in the browser](#solid-objects-in-the-browser). > earns its cost when the critical section outlives the transaction: a hold that > expires in ten minutes, work that must survive a restart, or a fan-in that > spans many jobs. See -> [Why not just use a transaction and a row lock?](#why-not-just-use-a-transaction-and-a-row-lock). +> [Why not just use transactions?](#why-not-just-use-transactions). ## Contents - [The programming model](#the-programming-model) -- [Why not just use a transaction and a row lock?](#why-not-just-use-a-transaction-and-a-row-lock) +- [Why not just use transactions?](#why-not-just-use-transactions) - [Is it worth installing here?](#is-it-worth-installing-here) - [Run it now with SQLite](#run-it-now-with-sqlite) - [What Solid Objects is for](#what-solid-objects-is-for) @@ -136,7 +136,7 @@ process.on("SIGTERM", () => controller.abort()) await runtime.run(controller.signal) ``` -## Why not just use a transaction and a row lock? +## Why not just use transactions? Often you should. If the whole job is read a row, decide, write it back, and answer the request, then a transaction with `SELECT ... FOR UPDATE` does that @@ -153,7 +153,7 @@ Any column named `expiresAt`, `scheduledAt`, or `nextRunAt` is evidence that the critical section already outlived the lock that was supposed to cover it. What follows such a column is a sweeper that looks for due rows, and then a race between that sweeper and the next writer of the same row. The column, the -sweeper, and the race are what an actor replaces. +sweeper, and the race are what a Solid Objects actor replaces. Three cases a lock cannot reach: @@ -182,6 +182,15 @@ identity is serialized on purpose, so making everything one identity makes a queue. Split an identity only when the domain can tolerate independent ordering and transactions. +High-QPS reads and hot identities are where this runtime stops being the right +tool on its own. [Solid Objects Pro](https://solidobjects.pro/) is a commercial +performance layer for the family that adds grouped commits, which coalesce +concurrent writes into fewer database commits; optional ephemeral operations, +which take loss-tolerant calls out of the durable journal; and materialized +projections, which build read models after commit so reads stop competing with +mailbox work. It ships for the Rails gem today, and the Node build is in +development. + [What Solid Objects is for](#what-solid-objects-is-for) has the pattern table, and [Choosing Solid Objects](docs/fit.md) is the longer guide. From 01df969a972133bb404fb5bc8ef9d620decf148d Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 25 Aug 2026 12:18:58 -0700 Subject: [PATCH 6/7] release: bump to 0.15.0 The README is what npmjs.com renders, and docs/ ships inside the tarball, so this work does not reach a reader until a release goes out. Published 0.14.2 still leads with the cart example and answers the lock objection nowhere. Minor rather than patch: the page a reader lands on is different, not corrected, and 0.15.0 puts the package back in step with the gem after the two drifted apart at 0.14.1 and 0.14.2. --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ package.json | 2 +- src/version.ts | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ad51a..361ef95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 0.15.0 - 2026-08-25 + +- Rewrite the first screen around the objection a reader actually has. The + README led with a shopping cart that appended to an array, which invites the + reply that one SQL statement already does it. It now leads with the ticket + sale from the homepage: 100 seats, a hold, a ten-minute expiry that frees the + seat, and a published count. That is the smallest example needing three + things from one number, and the three things are the argument. +- Answer "why not just use transactions?" in the first screen instead of + burying the fit sections. The section concedes the transaction and the row + lock first, including `navigator.locks` in the browser, then argues scope + rather than discipline: any `expiresAt` or `scheduledAt` column is evidence + the critical section already outlived the lock, and what follows it is a + sweeper and a race. +- Add "Is it worth installing here?", which names who should not install this, + and point readers with high-QPS reads or hot identities at + [Solid Objects Pro](https://solidobjects.pro/). +- Correct two claims. The realtime section said a value is published once per + change from the saving turn; the turn records the publication atomically, + while delivery is a separate worker and is at least once. Design provenance + said the API was redesigned around Web Components; there is no + `customElements` or `HTMLElement` in the package. +- Cut the README from 590 to about 540 lines by removing what `docs/` already + documented and what the page said three times, and add the table of contents + the standard-readme specification asks for above 100 lines. + ## 0.14.2 - 2026-08-24 - State that background pickup needs `runtime.run(signal)` diff --git a/package.json b/package.json index 62824aa..de2fdaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-objects", - "version": "0.14.2", + "version": "0.15.0", "description": "Race-free realtime state per application identity, backed by your SQL database", "type": "module", "license": "MIT", diff --git a/src/version.ts b/src/version.ts index ded049d..92d00d6 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.14.2" +export const VERSION = "0.15.0" From 62f2da4362a397094b8f5c1f63cb62d2b1e96431 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Tue, 25 Aug 2026 12:21:05 -0700 Subject: [PATCH 7/7] release: use 0.14.3, not 0.15.0 Nothing in this release changes behavior. The runtime is untouched and every change is prose, so semver calls this a patch. The earlier bump reached for minor to resync the version numbers with the gem, which is not a reason to spend a minor: the parity ledger tracks capability, not the number. --- CHANGELOG.md | 2 +- package.json | 2 +- src/version.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 361ef95..a0b7133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.15.0 - 2026-08-25 +## 0.14.3 - 2026-08-25 - Rewrite the first screen around the objection a reader actually has. The README led with a shopping cart that appended to an array, which invites the diff --git a/package.json b/package.json index de2fdaf..0814922 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-objects", - "version": "0.15.0", + "version": "0.14.3", "description": "Race-free realtime state per application identity, backed by your SQL database", "type": "module", "license": "MIT", diff --git a/src/version.ts b/src/version.ts index 92d00d6..0d29b78 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.15.0" +export const VERSION = "0.14.3"