Skip to content

Latest commit

 

History

164 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Solid Objects JS

CI npm

Open Source Durable Objects for JavaScript, in the SQL database you already run. No daemon, no broker, and no new datastore.

Build addressable TypeScript objects with serialized calls and durable state on SQLite, PostgreSQL, or MySQL. You don't need Cloudflare for this.

Concurrent calls for one identity cannot overwrite each other. Calls for different identities can run at the same time.

Define ordinary TypeScript classes and run them in ordinary Node.js processes. Solid Objects keeps the state, the queued operations, the retries, the reminders, the effects, and the realtime invalidations in the database the application already operates.

The same runtime also runs inside a browser worker on SQLite WASM, with durable actor state in the origin's private file system, and its offline writes can replay onto a Node or Rails backend over one shared wire contract. See Solid Objects in the browser.

Early release: the correctness core has automated coverage. That coverage includes the supported databases, the Chromium browser client, the browser runtime, process recovery, and the packaged artifacts. The TypeScript implementation is still new. There is one deployed first-party reference application. There is no measured scale and no third-party production use yet. Read the delivery boundaries before you use it for important 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.

The programming model

import { Actor, configure } from "solid-objects"
import { sqlite } from "solid-objects/database/sqlite"

class Cart extends Actor {
  static override readonly actorType = "Cart"

  items: string[] = []

  add({ sku }: { sku: string }): number {
    this.items.push(sku)
    return this.items.length
  }
}

const runtime = configure({
  database: sqlite({ path: "cart.sqlite3" }),
  authorizeMessage: () => true,
  authorizeQuery: () => true,
})

await runtime.install()

try {
  const cart = Cart.ref("cart-123")
  await Promise.all([cart.add({ sku: "blue-shirt" }), cart.add({ sku: "green-hat" })])
} 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.

install() prepares the database and starts nothing. The example above finishes because the caller's own path executes each call. A process serves background work only after runtime.run(signal) starts its roles, so a process that installs and then waits never claims a ready message. Nothing is lost while no process runs. The message stays ready until one does.

const controller = new AbortController()
process.on("SIGTERM", () => controller.abort())
await runtime.run(controller.signal)

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:

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.

What Solid Objects is for

Use Solid Objects when more than one request, job, or process can act on the same logical thing. The next action must then use the latest committed state of that thing. These are the stateful coordination patterns for which people often reach for Durable Objects:

Pattern One identity per What the object coordinates
Multiplayer, presence, or collaboration Room, session, or document Joins, moves, and edits commit in order; subscribers refresh from committed state
Reservations and expiring holds Show, resource, or stock item Availability checks and holds cannot interleave; a durable reminder can release an old hold
Checkout and account workflows Cart, order, account, device The current step, retries, and effect results return to the same ordered mailbox
Per-key rate limits API key, account, or device Token checks and decrements are serialized; a reminder can refill the bucket
Stateful agent sessions Agent session Messages and tool results apply in order and pending work survives a worker exit

The common shape is one durable coordination boundary with an application defined identity. Work for that identity is serialized, while unrelated rooms, carts, accounts, or sessions can progress concurrently. A single global rate limiter or another very hot identity is a poor fit because it becomes an intentional bottleneck. If one ordinary row transaction solves the problem, prefer that. See Choosing Solid Objects 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.

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.

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.

Running in a deployed application

Shuffle Up and Play is a deployed reference application. Two players create a table, load decks, and move cards. Realtime updates reach both browsers. Its source uses Node 24, TypeScript, SQLite, node:http, and ws. Each table code addresses one GameRoom actor that owns both seats, so mutations share one durable mailbox while each player receives a separately authorized projection.

The application and its tests exercise more than a counter-shaped happy path:

Production concern Verifiable application evidence
Concurrent mutations One GameRoom owns a table. Mailbox tests submit concurrent life, draw, and shuffle operations and assert the final committed state.
Controlled restarts Restart tests close and reopen the runtime against the same SQLite file, then assert recovery of committed state, an accepted asynchronous operation, an unfinished effect, and a scheduled reminder.
Persistent deployment The runtime uses SQLite; the container runs as an unprivileged user, and Kamal mounts a persistent volume.
Private realtime state Subscription policy and per-seat projection run on the server. HTTP and WebSocket tests assert that opponent card identities are absent from player payloads and shared invalidation envelopes.
External work Deck imports run as durable effects with success and failure callbacks. Tests cover both outcomes and prevent a superseded callback from replacing a newer deck result.
Transactional staged work A room operation stages an actor-to-actor log message and a database commit action. Tests cover rollback of staged messages and the metrics write.
Time and schema changes The actor defines versioned state migrations and a durable reminder. Tests load stored version-one state and run the reminder scheduler.
Operations and CI The operations tests exercise doctor, process, retention, and reconciliation APIs; server suites cover the dashboard, rate limits, and shutdown. The current main CI run passed typechecking, 171 tests, the build, the doctor, and a Docker image build.

Scope: the checked-in deployment configuration runs one Node process with SQLite on one Docker host. It shows a real deployed workload. It does not show a measured traffic level or every supported topology. Its deck-import effect reads an external API. An effect that writes to an external system still needs a stable idempotency key, because delivery is at least once. The restart tests close the runtime cleanly. The library verifies abrupt termination, PostgreSQL, MySQL, and multi-process lease fencing separately in its test matrix, failure-recovery demonstration, and correctness contract. Compare those guarantees and limits 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.

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 and the full architecture.

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.

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.
  • 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.

See Correctness and delivery semantics and Errors and recovery for the complete contract.

Realtime committed state

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:

import { Actor, broadcastValue } from "solid-objects"

class Room extends Actor {
  static override readonly actorType = "Room"

  version = 0
  privateHands: Record<string, string[]> = {}

  override observables(): Record<string, unknown> {
    return {
      version: broadcastValue(this.version),
      hands: this.privateHands,
    }
  }
}

version crosses the shared invalidation channel. hands contributes only its name when its real value changes. A reauthorized component endpoint can then render subscriber-specific state without a manual revision counter.

The browser package handles replay, reconnection, incarnation/revision fences, personalized payloads, and framework-neutral component refresh. Applications provide authentication, WebSocket transport, and rendering. See the browser protocol and authorization guide.

Solid Objects in the browser

The full runtime runs inside a browser module worker. Actors look exactly like they do in Node; the database is SQLite WASM, and persistent storage lives in the origin's private file system (OPFS), so actor state survives page reloads.

import { Actor, configure, sharedSqliteWasm } from "solid-objects/browser/host"

class Counter extends Actor {
  static actorType = "Counter"

  count = 0

  increment({ amount = 1 } = {}) {
    this.count += amount
    return this.count
  }
}

const runtime = configure({
  database: sharedSqliteWasm({ path: "app.db" }),
  authorizeMessage: () => true,
  authorizeQuery: () => true,
})
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:

  • 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.

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 (solid-objects-ruby): SolidObjects::Transmission.receive accepts the same envelopes as the Node ingest receiveTransmitEnvelope, dedups on the same transmit:<effectId> 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:

class TransmitController < ApplicationController
  def create
    head :forbidden and return unless authenticated_device?

    SolidObjects::Transmission.receive(JSON.parse(request.body.read))
    head :ok
  end
end

The Ruby side of the family shipped in solid_objects 0.14.0, released the same day as this package's 0.14.0 (solid-objects-ruby#49).

The wire shapes are documented in the browser protocol, the API in the public API reference, and the platform boundaries in supported versions.

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 holds the sourced comparison for each 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

Supported versions records the exact CI matrix 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.

Administration remains available through the JSON CLI and typed runtime managers. See Operations, the dashboard guide, and Configuration.

Design provenance

Solid Objects JS is a Node.js and TypeScript implementation. The Ruby solid_objects design informed it. It began at the 0.12 capability generation, because the first implementation targeted the Ruby 0.12 contract. That number does not represent 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 parity ledger records capability relationships and deliberate runtime differences.

The Ruby project first appeared publicly on August 6, 2026, and this TypeScript repository on August 13, 2026. Both remain early releases. The mtg-playmat application uses the Ruby actor and realtime design. Shuffle Up and Play uses the TypeScript package in the deployed Node and SQLite topology above.

Documentation

License

Solid Objects is released under the MIT License.

About

Open Source Durable Objects for Node, backed by your existing SQL database

Topics

Resources

Contributing

Security policy

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages