Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ jobs:
- run: pnpm run check
- run: pnpm run test:coverage
- run: pnpm run build
- run: pnpm run test:cloudflare
- run: pnpm run check:cloudflare
- run: pnpm run pack:check
- run: pnpm run test:package
- run: pnpm run test:recovery
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ test-results/
.DS_Store
.claude/
.codex/
.wrangler/
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## 0.14.7 - 2026-09-05

- Read SQL message results and completion status in one statement so concurrent
completion cannot return a stale `null` result.
- Add `solid-objects/core` and the Cloudflare Durable Objects runtime backend.
Each actor identity owns SQLite state, a durable mailbox, retry state,
reminders, and effect/message outboxes. Request IDs recover ambiguous RPC
acceptance; incarnation and execution generations fence stale commits.
- Add hibernating session Durable Objects for existing browser subscriptions,
including multi-actor connections, fresh authorization, personalized payloads,
revision fencing, and durable subscription cleanup.
- Share actor turn evaluation across SQL and Cloudflare. Keep Workers types and
imports separate from the Node target. Add Workers integration tests, a
runnable example, bundle checks, and an explicit backend capability matrix.
The Cloudflare backend is experimental pending deployed failover/soak testing.

## 0.14.6 - 2026-09-03

- Poll effects, reminders, and broadcasts through ordered indexes installed by
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@

**Open Source Durable Objects in your Node app.**

Deploying on Cloudflare? The [Durable Objects backend](docs/cloudflare.md) runs
the same actor API on Workers, with durable mailboxes, alarms, and browser
subscriptions. Start with the [Cloudflare example](examples/cloudflare/README.md).

In a shopping cart, paying twice at the same time is a big problem. The payment provider might time out, and your Node site could be restarting before recovery finishes.

To deal with this safely, you often need logic scattered between 7-10 files like database row locks, Redis locks, delayed jobs, retries, and cleanup code to keep that process straight. They are not all large, but they must agree about the same payment state and failure rules. That coordination is the difficult part.
Expand Down
22 changes: 22 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ The package exports one server entry point plus database, wake-up, and browser
subpaths. The TypeScript declaration files are authoritative for exact generic
signatures. This index explains the supported role of every export.

## `solid-objects/core` and `solid-objects/cloudflare`

`solid-objects/core` exports portable actor definitions, errors, reference types,
the `ActorRuntime` interface, and request-scoped `withRuntime(runtime, callback)`.
It does not initialize a Node platform or SQL driver.

`solid-objects/cloudflare` exports `CloudflareRuntime` and `createRuntime({ backend })`,
`durableObjects({ namespace, sessions })`, `createDurableObjectsHost({ actors, configure })`,
and `createDurableObjectsSessionHost({ backend, resolveAuthorizationContext, maxSubscriptions })`.
`CloudflareConfiguration` supplies host policies, limits, instrumentation, and effects.
`DurableObjectsBackend`, `ActorNamespace`, and `SessionNamespace` describe the bindings.

Actor references retain the operation, snapshot, send, and message-result APIs.
`lookupMessage()` recovers acceptance by request ID; `actorAdministration()` provides
bounded per-actor dead-letter and reminder operations. `openWebSocket()` bridges an
authenticated HTTP upgrade to a session Durable Object.

`EnqueueOutcomeUnknown` reports ambiguous RPC acceptance and carries recovery
identifiers. `UnsupportedCapability` rejects facilities requiring the shared SQL
runtime. See [Cloudflare Durable Objects](cloudflare.md) for configuration,
authorization, capability boundaries, and release validation.

## `solid-objects`

### Runtime and actors
Expand Down
8 changes: 5 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ at-least-once delivery and retry backoff. Per-actor order comes from an
ordered drain up to the claimed effect's mailbox sequence, and the server
ingest deduplicates on the effect id through the normal idempotency-key path.

Solid Objects provides database-backed state coordination for Node.js and the
browser. It does not reproduce Cloudflare's placement or edge-runtime
guarantees.
The SQL runtime provides database-backed coordination for Node.js and the
browser. The optional [Cloudflare backend](cloudflare.md) uses native Durable
Objects for placement, private SQLite storage, activation, alarms, and
hibernating browser connections. Its runtime boundary shares actor evaluation
without importing SQL workers or their process/lease administration.
155 changes: 155 additions & 0 deletions docs/cloudflare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Cloudflare Durable Objects

Import actor definitions from `solid-objects/core` to share them between Node
and Workers. On Cloudflare, import `createRuntime`, `durableObjects`,
`createDurableObjectsHost`, and `createDurableObjectsSessionHost` from
`solid-objects/cloudflare`.

The [runnable example](../examples/cloudflare/README.md) includes both Durable
Object classes, Wrangler bindings, migrations, and a public shopping cart.

## Configure the hosts

```typescript
import { createRuntime, durableObjects, withRuntime } from "solid-objects/cloudflare"

const runtime = createRuntime({
backend: durableObjects({ namespace: env.ACTORS, sessions: env.SESSIONS }),
})

const counter = runtime.ref(Counter, "counter-1")
const count = await counter.with({ authorizationContext: subject }).increment()
await withRuntime(
runtime,
() => Counter.ref("counter-1").with({ authorizationContext: subject }).count,
)
```

Define the actor host once with `createDurableObjectsHost({ actors, configure })`.
`configure(env)` returns a `CloudflareConfiguration` with the same backend
bindings, authorization policies, and an `effects` map of named handlers. A
handler receives the existing effect arguments and `EffectContext` with its
stable delivery ID. Definitions and configuration stay inside the deployment;
they are not transmitted through RPC.

Use `runtime.ref()` or request-scoped `withRuntime()` in Worker handlers.
`Actor.ref()` within actor operations and lifecycle callbacks retains its
runtime across awaits. The backend does not set a global default runtime.

The host supplies storage and scheduling, so there is no `install()` or
`run()` step. Configure both exported classes using `new_sqlite_classes` and
enable `nodejs_compat` for async context propagation. Library schema migrations
run synchronously on activation and use a separate migration ledger.

## Guarantees and recovery

An actor type plus its string-normalized ID routes to one Durable Object.
Operations and getters enter its ordered durable mailbox. Snapshots read one
committed state image without entering the mailbox. A retryable head blocks
later turns, and permanent failures pause that actor until operator recovery.

State, results, effects, outbound messages, reminders, and the next alarm commit
together. Actor code executes outside that short transaction. Destruction and
restart generations fence stale commits. State migrations and read-only
projection checks use the same implementation as the SQL runtime.

Delivery is at least once. An effect can reach its external service before the
acknowledgement is stored. Deduplicate using `EffectContext.id`. `sendTo()` stores
an outbound intent in the source object and deduplicates acceptance in the
destination. This is eventual delivery, not a cross-object transaction.
Actors cannot synchronously call or wait on other actors.

One alarm covers the next due mailbox turn, retry, reminder, outbox delivery,
subscription expiry, or retention deadline. Persistent retry state supplements
Cloudflare's finite automatic alarm retries. Idle objects have no recurring
polling loop. Completed records default to 30 days of retention; dead letters
remain available for inspection. Idempotency receipts expire with their messages,
so callers must not rely on deduplication after the retention window.

An invocation timeout after acceptance raises `SyncTimeout` with a recoverable
`messageReference`. If RPC fails before an acceptance response arrives,
`EnqueueOutcomeUnknown.details` contains `actorType`, `actorId`, and `requestId`:

```typescript
const message = await runtime.lookupMessage({
...error.details,
authorizationContext: subject,
})
if (message) await message.wait({ authorizationContext: subject })
```

A missing lookup is not proof that an outstanding enqueue cannot still arrive.
Look up again, or retry the application operation with its original explicit
idempotency key. Lookup requires query authorization for `__lookupMessage__`;
an existing message also requires authorization for its original operation.

Use `runtime.actorAdministration({ actorType, actorId, authorizationContext })`
for `deadLetters()`, `retryDeadLetter(id)`, `reminders()`, and
`resumeReminder({ name, runAt })`. Inspection is bounded to 1,000 records per
call. These operations call `authorizeAdministration` with resource
`actor:` followed by the canonical JSON identity tuple.

## Realtime sessions

The application authenticates its HTTP upgrade before calling
`runtime.openWebSocket({ sessionId, expiresAt })`. Forward the returned response
to the browser. `sessionId` must be an opaque application session reference,
not a bearer credential or serialized user object. Never forward client-supplied
`X-Solid-Session-*` headers to the session namespace.

The session host's `resolveAuthorizationContext({ sessionId, environment })`
loads current authorization data and returns JSON, or `null` when access has
expired or been revoked. It runs when opening the connection, subscribing, and
delivering events. Actor policies still authorize subscriptions and each named
personalized payload. Authentication remains application-owned.

One hibernating WebSocket multiplexes up to 100 actors by default. Configure
`maxSubscriptions` on the session host to change that limit. The browser client,
version-1 envelopes, value versus invalidation projections, personalized
payloads, and revision fences use the existing protocol.

The handshake uses Fetch because WebSocket upgrades cannot travel through
ordinary RPC. Actor-to-session events use RPC. Durable registrations survive
hibernation; disconnect and expiry trigger registration cleanup. Reconnects
replay current committed projections, not every event missed while offline.

## Capability boundaries

| Surface | Cloudflare backend |
| ------------------------------------------------------------------ | -------------------------------------- |
| Actor operations, getters, snapshots, state migrations | Supported |
| Durable sends, results, retries, rejection, destruction | Supported |
| Effects, reminders, cross-actor `sendTo()` | Supported |
| Browser subscriptions and personalized payloads | Supported through session hosts |
| Actor-scoped dead letters and reminder administration | Supported |
| `commitAction`, shared application SQL transactions | Unsupported |
| Global repository, reconciliation, process controls, SQL dashboard | Unsupported |
| Process-local `runtime.realtime.connect()` / server `ref.live` | Unsupported; use browser subscriptions |
| SQL-to-Durable-Objects data migration | Not provided |

Unsupported runtime facilities raise `UnsupportedCapability`; staging a commit
action permanently fails that turn before any state or intent commits.
Application and effect code must run within Workers' APIs and execution limits.
Arbitrary JavaScript is cooperative; caller timeout does not preempt it.
Eviction may discard private fields, and does not guarantee `onDeactivate()`.

State defaults to a 1 MiB limit. Encoded records, including indexed copies of
fields, must also fit Cloudflare's 2 MB SQLite row limit. Oversized records raise
`PayloadTooLarge`; increasing a configured payload limit cannot bypass the
[platform limits](https://developers.cloudflare.com/durable-objects/platform/limits/).

Cloudflare hosting is JavaScript-specific. It does not change Ruby's SQL backend
or the guarantees of the existing SQL drivers.

## Validate and release

`pnpm run test:cloudflare` runs the Workers integration suite. `pnpm run check`
type-checks the separate Workers target alongside Node; `pnpm run check:cloudflare`
checks the example bundle and its imports. Keep backend schema changes additive.
Once an actor persists a newer state version, rollback to older actor code may
be unsafe; test that rollback against the stored version before deploying it.

Before calling this backend stable, run a deployed soak in a disposable
namespace covering alarms without traffic, eviction/restart recovery,
hibernation, and slow external effects. Local Workers tests do not establish
Cloudflare production failover behavior.
19 changes: 19 additions & 0 deletions docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ such boundary between a gem and its dependents.

## Runtime and correctness

SQL waiters read results and status from one statement. Ruby already checks
completion and returns the result from the same loaded message; no Ruby change
is needed for the JavaScript stale-result race fix.

| Capability | Status | TypeScript shape or remaining work |
| ------------------------------------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Actor registry, durable identity, JSON state, and adjacent state migrations | Native | Ordinary classes, static actor types, inferred state, explicit migrations, and isolated runtime context across every actor-instance callback. |
Expand Down Expand Up @@ -180,6 +184,21 @@ and Rails to Node) ran in solid-objects-ruby#49; the one disagreement it
found (the optional `arguments` default) is fixed and pinned by the shared
fixture.

## JavaScript-only: Cloudflare hosting

The experimental `solid-objects/cloudflare` backend hosts each actor identity
in a SQLite-backed Durable Object. Portable actor definitions, turn evaluation,
state migrations, and authorization are shared with the SQL runtime. Alarms
drive durable mailbox/outbox recovery, and session Durable Objects host
hibernating browser subscriptions.

Ruby hosting on Cloudflare is **Not applicable**. This backend introduces no
change to Ruby's SQL behavior or roadmap. Its JavaScript support is **Partial**
until deployed failover and soak validation completes. Shared SQL transactions,
commit actions, fleet administration, reconciliation, and the SQL dashboard
are explicit unsupported capabilities. The [backend matrix](cloudflare.md#capability-boundaries)
records these boundaries separately from SQL-runtime parity.

## Rails-specific surfaces

Rails generators, Active Record models/controllers, Turbo rendering, and
Expand Down
23 changes: 12 additions & 11 deletions docs/support.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@

## Runtime support

| Component | Supported or tested range |
| --------------- | ----------------------------------------------------------- |
| Node.js | 24.4.0 or newer; CI runs 24.4.0 and 24.15.0 |
| TypeScript | 5.9 or newer for TypeScript applications |
| SQLite | Node's built-in `node:sqlite` on the supported Node runtime |
| PostgreSQL | 14 or newer; CI runs 14 and 18 |
| MySQL | 8.0 or newer with InnoDB; CI runs 8.0 and 8.4 |
| Redis wake-up | Optional; CI runs Redis 7 |
| Browser client | Chromium through Playwright |
| SQLite WASM | `@sqlite.org/sqlite-wasm` 3.50 or newer; optional |
| Browser runtime | Chromium through Playwright; OPFS for persistent storage |
| Component | Supported or tested range |
| ------------------ | ------------------------------------------------------------------ |
| Node.js | 24.4.0 or newer; CI runs 24.4.0 and 24.15.0 |
| TypeScript | 5.9 or newer for TypeScript applications |
| SQLite | Node's built-in `node:sqlite` on the supported Node runtime |
| PostgreSQL | 14 or newer; CI runs 14 and 18 |
| MySQL | 8.0 or newer with InnoDB; CI runs 8.0 and 8.4 |
| Redis wake-up | Optional; CI runs Redis 7 |
| Browser client | Chromium through Playwright |
| SQLite WASM | `@sqlite.org/sqlite-wasm` 3.50 or newer; optional |
| Browser runtime | Chromium through Playwright; OPFS for persistent storage |
| Cloudflare runtime | Experimental; Workers integration tests and Wrangler bundle checks |

The package is ESM-only. PostgreSQL, MySQL, Redis, and SQLite WASM require
their optional peer dependency. The Node SQLite adapter has no driver
Expand Down
Loading