This ledger tracks capability parity with the Ruby solid_objects gem.
Parity preserves a capability and its correctness or security boundary. It does
not copy a Rails API into Node.
Reference: Ruby solid_objects 0.14.0. The JavaScript package began at the
Ruby design's 0.12 capability generation; that version number did not imply
earlier JavaScript releases.
The Node 0.14.0 implementation has capability parity with that reference. Its
relational runtime, correctness boundaries, administration, diagnostics,
operator dashboard, realtime projections, browser behavior, and supported
adapters have native equivalents. Transport- and framework-neutral JavaScript
APIs replace the Rails-specific render surfaces. Three rows below are explicit
scope boundaries that the Ruby reference shares: the partial guard row, the
backpressure row, and the shared planned result-lookup row. They are not missing
Ruby capabilities.
0.14.0 also adds runtime.enqueueInternalMessage(),
runtime.enqueueInternalMessageInTransaction(), and
runtime.snapshotWithIncarnation(). These are Node-only integration points
for a host package (such as a future commercial scaling layer), not ported
Ruby capabilities. Ruby's equivalent primitives
(SolidObjects::Mailbox#enqueue, ActorSnapshot) are already reachable
in-process, and they need no dedicated public API. Node's exports map enforces
a package-privacy boundary between a package and its dependents. Ruby has no
such boundary between a gem and its dependents.
Operation-reference typing is runtime-specific: TypeScript infers scheduled and transmitted operations from the concrete receiver, and checks literal effect callback names. Ruby offers opt-in RBS generation from declared application types in solid-objects-ruby#66. Both preserve runtime operation validation and global effect/commit-action names; this does not imply automatic TypeScript-style inference in Ruby.
- Native: the TypeScript runtime provides the capability in a Node-native shape.
- Partial: the core exists, but an important Ruby guarantee or operational surface is missing.
- Planned: no defensible equivalent exists yet.
- Not applicable: the Ruby feature belongs specifically to Rails, Active Record, Action Cable, Turbo, or Ruby language constraints. The equivalent Node capability is tracked separately when one is useful.
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. |
| Fluent committed calls and background delivery | Native | await reference.operation() and reference.send.operation(). |
| Ordered mailbox, sequence allocation, idempotency, retries, dead letters, leases, renewal, and fenced commits | Native | Relational ready/claimed membership tables, distinct generated request IDs and caller idempotency keys, durable history, adapter-appropriate sequence locking, and PostgreSQL/MySQL row locks held from fence validation through commit. |
| Domain rejection and strict poison ordering | Native | Rejections accept JavaScript identifier-style codes and roll back without retry; invalid codes fail terminally, while retryable failures block later operations until completion or dead-lettering. |
| Bounded activation passes and hot-actor fairness | Native | Configurable turn-count and elapsed-time budgets bound each pass, then move only that actor's already-due memberships behind actors already waiting. |
| Bounded claim candidate scan | Native | A configurable ordered scan continues to another ready actor when a worker loses the first candidate's lease race. |
| Backpressure and payload caps | Partial | Serialization enforces a shared maximum JSON nesting depth, raising InvalidPayload, and an optional caller-supplied maxBytes limit, raising PayloadTooLarge; reminder names are bounded to 255 characters. Distributed per-actor rate limits, global admission control, and cache-capacity eviction are not planned in either runtime. They are hot, request-path, and loss-tolerant, so one durable ordered message per check is the wrong shape. Solid Objects Pro answers them with grouped and ephemeral operations, which fit describes. |
| Idle activation cache | Native | Long-running workers retain hydrated actors under renewable fenced leases, restore public state after failed turns, and release on timeout, fairness yield, lease loss, or shutdown. |
| Transactional effects and outcome operations | Native | At-least-once handlers receive immutable stable effect, attempt, source-message, and actor identity; success and failure operations also receive the originally staged arguments for correlation. Typed callback envelopes are exported. |
| Actor-to-actor delivery | Native | sendTo(reference).operation() stages delivery in the source actor commit. |
| One-shot and recurring reminders | Native | Scheduling, replacement events, catch-up policy, stale-claim recovery, pausing, authorized inspection, and idempotent resume are implemented. |
| Same-database commit actions | Native | Registered actions receive source-message identity, mailbox sequence, activation generation, and the fenced transaction connection. |
| Ambient transaction rejection | Native | Committed calls and message waits fail before blocking when the current async context already owns a transaction on the Solid Objects adapter. |
| Direct application-write isolation during actor code | Partial | guardApplicationDatabase() fails closed for operations, projections, migrations, and commit actions; only the supplied fenced commit-action connection may write. Unwrapped clients cannot be intercepted. |
| Committed snapshots | Native | snapshot() returns authorized persisted fields and inferred getters from one read-only committed state image; realtime replay reads explicit observables without mailbox history. |
| Actor destruction and incarnation fencing | Native | Authorized cascading deletion creates a fresh instance ID on recreation; an authorized waiter receives ActorDestroyed when that incarnation disappears. |
| Result recovery and sync timeout diagnostics | Native | Status, result, and wait reauthorize the stored operation; terminal failure raises structured MessageFailed; whole-call adapter deadlines distinguish enqueue, wait, database, activation, and mailbox blockers. |
| Result lookup by request ID and idempotency key | Native | runtime.findBy({ requestId }) and reference.findBy({ idempotencyKey }) rebuild a MessageReference, authorized with the hook the original call ran. An actor remembers the keys of its own finished turns, so a key lookup separates a pruned message from one that never existed. |
Effect callback envelopes are typed with EffectFailurePayload,
EffectSuccessPayload, and SerializedError in both SQL and Cloudflare.
Ruby RBS contracts are tracked in cardmagic/solid-objects-ruby#64 and preserve
Ruby field names; this does not change runtime delivery semantics.
| Capability | Status | TypeScript shape or remaining work |
|---|---|---|
| Process registration, heartbeats, stale claim recovery, and graceful shutdown | Native | Runtime roles persist host, PID, runtime versions, draining and stopped transitions, cooperative cancellation, and a bounded shutdown deadline; cleanup recovers stale claims. |
| Failed-role replacement | Native | Built-in and registered roles are rebuilt through their factories with capped backoff; shutdown is the terminal replacement boundary. |
| Additional supervised components | Native | registerComponent() builds, validates, runs, and stops application components with the runtime. |
| Dead-letter inspection and retry | Native | runtime.deadLetters provides deny-by-default immutable inspection and idempotent durable retry linkage. |
| Reconciliation reads | Native | Authorized cursor pages cover active, quiet, and orphaned instances; bounded state batches are migrated and deeply frozen. |
| Message, process, and opt-in instance retention | Native | Supervised scheduling bounds message and process growth; authorized manual APIs add preview and keep destructive instance expiration explicit. SQL instance pruning uses an index on actor type and update time. |
| Doctor and schema verification | Native | Structured checks cover configuration, schema/version shape, adapter server versions, neutral-context policy probes, live roles, and a targeted round trip. |
| CLI | Native | The packaged executable loads an application runtime and exposes start, diagnostics, processes, dead letters, reminders, and explicit retention pruning as JSON. |
| Operator dashboard | Native | The opt-in solid-objects/web export provides Fetch and Node/Connect mounting, authorized runtime views and actions, session-backed CSRF, filtering, paging, charts, and immutable extension hooks. Matches the Ruby dashboard's own documented limits: no audit trail of admin actions, dead-letter retry is one at a time, and pause sets a flag rather than interrupting an in-flight turn. |
| Structured instrumentation | Native | An isolated transport-neutral sink emits immutable lifecycle metadata and structurally excludes application payloads. |
| Large committed state warning | Native | warnStateBytes reports one solid_objects.state.large event, holding the actor type, actor ID, byte count, and threshold, when a committed image passes a 128 KB soft threshold. The event holds no application state, and it reports after the commit. The Ruby gem carries the same event and the same 5 MB hard default from 0.14.3, as warn_state_bytes. Its threshold defaults to 64 KB rather than 128 KB, because its measured curve falls sooner: it keeps 55% of its empty-state throughput at 13 KB, where this package keeps 98% at 16 KB. |
| Public test helper | Native | runtime.testing provides role-selective deterministic draining, explicit-time due-reminder execution, and dependency-ordered reset without relying on cascades. |
| Capability | Status | TypeScript shape or remaining work |
|---|---|---|
| SQLite | Native | Uses built-in node:sqlite, serialized process-local access, bounded transient writer retries, foreign keys, strict tables, database time, and deadline-bounded access and lock waits. |
| PostgreSQL | Native | Optional pg 8.23 peer, bounded pooling, 64-bit schema, row-locked sequences, server checks, and deadline-bounded pool, statement, and lock waits. |
| MySQL | Native | Optional mysql2 3.23 peer, bounded pooling, InnoDB schema, row-locked sequences, scoped deadlock retry, and deadline-bounded pool, query, and lock waits. Ruby also tests a second client, trilogy; Node has no comparable second MySQL client, so only mysql2 is tracked here. |
| Durable polling fallback | Native | Every role progresses without a notification service. Effects, reminders, and broadcasts use canonical ordered polling indexes; PostgreSQL and MySQL lock only the selected row with FOR UPDATE SKIP LOCKED. Broadcasts and reminders compare separate available and stale-recovery probes, preserve the oldest-first choice, and retry past candidates locked by another claimant. |
| In-process wake-up | Native | A generation-based default adapter prevents claim-to-wait signal loss; commits wake role-specific waiters and polling remains the fallback. |
| PostgreSQL wake-up | Native | database.wakeUp() uses one dedicated event-driven client, role-specific LISTEN/NOTIFY, generation fencing, reconnectable listeners, and durable polling fallback. |
| Redis wake-up | Native | An optional redis peer provides role-specific Pub/Sub over separate lazy publisher/subscriber connections, with bounded failures and durable polling fallback. |
Both runtimes select a wake-up adapter automatically. wakeUp takes a name or
an adapter and defaults to "automatic", which prefers a configured Redis URL,
then PostgreSQL notifications, then polling. Selection proves the PostgreSQL
path with a probe notification, because LISTEN does not survive a transaction
pooler. Each runtime reports what it installed, whether that crosses processes,
its measured floor, and why, and warns once when live processes share a
database and the installed adapter cannot reach them. MySQL still polls in both
runtimes, because MySQL has no notification channel.
A requested adapter that the environment cannot provide polls instead and says so in both runtimes, rather than claim a cross-process wake-up that cannot happen. Only a name that does not exist is refused.
Three details differ, and each follows from the language rather than from the
feature. The pooled-session warning is emitted once per runtime here and once
per process in Ruby, because this runtime supports several runtimes in one
process. A configured adapter must implement watch, notify, and close
here, while Ruby requires signal and wait and treats watch and stop as
optional, which is each runtime's own adapter contract. A selection that a later
edit of the settings makes impossible is reported once here and raised in Ruby;
both runtimes refuse an unknown name when the configuration is built, so this
only reaches code that changes the setting afterwards.
Both runtimes scope dead letters by kind through one receiver, retry a dead message, effect, or broadcast, redrive a whole scope as a durable and idempotent task, bound that task to the rows that were dead when it started, advance it in bounded batches, and write one administration event per retry and per task transition, in the transaction that causes it. The filters, the resource names, the audit actions, and the configuration defaults match.
Two details differ. This runtime filters on a failed_at_ms stamp that schema
version 11 adds, while Ruby filters on the updated_at column Active Record
already maintains. The Durable Objects engine keeps its own message and outbox
tables inside each object, so these scopes cover the SQL backends here; its
deadLetters call is unchanged.
Both runtimes rebuild a MessageReference from a request id or an idempotency
key, scope each lookup through the receiver that matches its index, authorize it
with the hook the original call ran, and report absence, an unregistered actor,
and a refusal the same way. outcome reports the status, result, error,
rejection, and attempt count in both.
Three details differ, and all come from the runtimes rather than the feature.
This runtime stores a result for every completed message, so a lookup answers
one for asynchronous work; Ruby stores a result only for sync delivery, so a
lookup there answers the status and the error but not the result. This runtime
needed a new unique index on request_id, added as schema version 12, because
its table constrained the pair (actor_type, actor_id, request_id); the Ruby
schema has carried a global unique index since its first migration. The error
record also differs: Ruby's ErrorRecord carries class_name, message, and
backtrace, while this runtime persists only name and message, because
safeError has never stored a stack. An outcome here reports what failed, not
where.
Both runtimes tell a pruned message from one that never existed the same way.
An actor remembers the idempotency keys of its own last
retained_idempotency_keys / retainedIdempotencyKeys finished turns, written
in the instance row the executor updates anyway, so a key lookup raises
MessagePruned for a message retention removed and answers absent for a
message that never existed. Each remembered key carries its operation and original arguments, so the
pruned answer runs the same authorization a lookup of the surviving row
would. Both also bound the serialized memory, because an
idempotency key has no length limit and the memory outlives the message row; an
actor drops its oldest keys until the list fits. The original operation selects the message or query authorization hook in both. A request id lookup answers absent in both cases,
because the runtime generates a request id and no actor remembers one.
Remembered arguments count toward the serialized memory limit and remain until the entry is evicted or the instance is removed. Entries from older versions that lack arguments return absence after pruning because their original authorization cannot be reproduced.
The Durable Objects backend answers a key lookup and a request id lookup
through the actor that holds the row, and it remembers keys in the same
instance record. It cannot answer findBy({ requestId }) without a reference,
because a Durable Object indexes only its own messages, so that form raises
UnsupportedCapability.
| Capability | Status | TypeScript shape or remaining work |
|---|---|---|
| Explicit observable projection and durable invalidations | Native | observables() is opt-in and invalidation-only by default. broadcastValue() sends changed values; broadcastInvalidation() explicitly sends only changed names while comparing the real value. Private or subscriber-specific values belong behind invalidation-only component endpoints or in typed payloads. |
| Action Cable channels and signed stream names | Not applicable | runtime.realtime provides authenticated transport-neutral sessions; the host owns its HTTP/WebSocket server and authentication. |
| Authorized subscriptions | Native | Each request is denied by default and authorized before actor lookup; sessions replay committed observables and fence ordered durable revisions. Multi-process hosts explicitly bridge their shared transport. |
| Turbo scalar replacement | Not applicable | The browser client exposes invalidations to application rendering code. Framework adapters can be separate packages. |
| Keyed component refresh, morph/replace, and batch coalescing | Native | A typed framework-neutral registry selects explicit dependencies, coalesces batch requests, aborts superseded work, fences each target, and delegates synchronous application strategy to the host. |
| Personalized payload broadcasts | Native | Static typed projections run against committed state under each fresh subscriber context, reauthorize as queries, isolate failures, and carry independent revision fences. |
| Real-browser compatibility suite | Native | Playwright exercises subscription replay over native WebSocket, incarnation/revision fences, payload delivery, component batching, and cancellation in Chromium. |
0.14.0 ships an in-browser runtime with SQLite WASM storage
(#17). The
runtime itself is a JavaScript-only capability: the Ruby gem has no browser
target, so no Ruby parity row exists for milestones M1 through M3. The
transmit family (milestone M4) started here but is not JavaScript-only; the
next section tracks it as a shared capability. All four milestones are
complete:
- M1: the shared modules no longer import Node built-in modules, a
registered platform factory supplies async context propagation, and
pnpm run checkenforces a Node-free import graph for the browser-safe modules. - M2:
solid-objects/database/sqlite-wasmimplements theDatabasecontract on SQLite WASM. The full runtime passes a round-trip test against it, and Playwright proves OPFS persistence across a page reload. - M3:
solid-objects/browser/hosthosts the full runtime in a browser module worker on OPFS storage, with durable actor state across page reloads proven in Chromium.solid-objects/browser/tab-hostelects one leader per origin with the Web Locks API and serves every tab over aBroadcastChannel; Playwright proves shared state across two tabs and failover with durable continuation after the leader tab closes.solid-objects/database/shared-sqlite-wasmgoes further: it moves the election behind theDatabaseseam, so every tab runs the ordinaryconfigure -> install -> refflow and the runtime's own leases and fencing arbitrate the tabs' workers. The plan named aSharedWorkeras the host; Web Locks election between dedicated workers replaced it, because OPFS sync access handles exist only in dedicated workers. - M4:
solid-objects/transmitdrains the local effects outbox to a server runtime with at-least-once delivery, per-actor order, and an idempotent server ingest. Vitest proves order under transmit failures, replay deduplication, and recovery after an offline period, on SQLite, PostgreSQL, and MySQL.
reference.live (the solid-objects/signals entry) adapts committed
actor state to the proposed standard JavaScript signals API, so
signal-consuming renderers track actors with no manual registration. No
Ruby row exists because the slot it fills is already native in Rails:
the gem's Turbo and Action Cable component surface re-renders partials
from the same committed observables. Each runtime renders with its
ecosystem's primitive. Parity here preserves the guarantee: views track
committed state under revision fencing and the same privacy model.
The transmit family is the one part of the browser work that both runtimes
share. The Ruby gem ships it in 0.14.0
(solid-objects-ruby#49,
from proposals #47
and #48):
SolidObjects::Transmission.receive is the ingest, and Actor#transmit
with register_transmit is the staging side. Identifiers differ by
runtime idiom, but both sides guarantee the same wire contract:
- envelope keys are camelCase (
effectId,actorType,actorId,operation, and an optionalargumentsthat defaults to an empty object); - the ingest idempotency key is
transmit:<effectId>, byte for byte; - a replay with changed arguments raises the idempotency conflict on both sides and leaves the first application intact.
compatibility/transmit-envelopes.json is committed to both repositories
with a consuming test on each side, so the contract is enforced from both
sides of the repository boundary. Manual cross-runtime QA (Node to Rails
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.
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 records these boundaries separately from SQL-runtime parity.
Rails generators, Active Record models/controllers, Turbo rendering, and Action Cable are not copied into this package. The Rack dashboard is represented by the framework-neutral Fetch and Node adapter, renderer callbacks, and the same authorization and CSRF boundaries.
Both runtimes maintain heartbeats during effect execution and retry failed
updates at the configured interval, reporting process.heartbeat_failed.
| Capability | Status | Contract |
|---|---|---|
| Abandoned SQL effect recovery | Native | Stable emit handles, automatic retirement, staged status checks, durable callbacks, and extending heartbeat grace in both languages. |
| Shared process-heartbeat recovery on Cloudflare | Not applicable | Durable Objects has no shared SQL process registry; recovery options and intents fail before commit. |
SQL effect recovery uses the same contract in Ruby and JavaScript: one emit
returns a stable handle; onRecovery/on_recovery opts into atomic retirement
and a durable callback; optional onStatus/on_status answers explicit staged
checks. Per-effect recovery timeouts extend the runtime heartbeat threshold
(milliseconds in JS, seconds in Ruby). Cloudflare returns emit handles but rejects
process-heartbeat recovery options and intents before commit. See
the transaction protocol.