- Add four comparison pages in
docs/compare/: Restate virtual objects, DBOS Transact, RivetKit actors, and Graphile Worker named queues. Each page compares one real task, says when the other system is the better choice, and links the primary sources that were checked on October 15, 2026. The Solid Objects code on each page is inexamples/compare/and runs in the test suite.docs/comparisons.mdand the README link the pages. An evaluation on October 15, 2026 found that Claude Code, with web search, searched only for these names and never found Solid Objects. pnpm run check:documentationnow checks the comparison pages. Each page must embed its current example files, link only to files and headings that exist, and use no em dash or en dash.docs/comparisons.mdmust link each page.
- The agent guide now lists the import path, the call, and the driver of each
SQL database adapter, and it shows how to cancel a reminder with
unschedule(). Agents in the Track B evaluation invented the namessolid-objects/database/postgresandthis.cancelReminder().
- Version 1.0 freezes the public API under semantic versioning. docs/versioning.md gives the promise and its limits. The Cloudflare backend stays experimental and is outside the promise. Version 1.0 is not a claim of measured scale: the benchmarks are development measurements, and longer soak tests are open work.
- Breaking: a turn that calls
schedule()and then calls no operation on the result now fails withScheduleWithoutOperation. The runtime does not retry that turn, and the message becomes a dead letter. Before this release, such a call stored no reminder and gave no error. Correct code, such asthis.schedule({ at }).expire(), does not change. The turn can keep the result and call the operation later. Aschedule()call with no operation inobservables()fails the turn withQueryMutatedState. Ruby has the same behavior. - The package description now starts with the problems that the package
solves, and the keywords add
race-conditions,reservations, andtransactional-outbox. The README has a status statement and names the projects that Solid Objects is not. - The README and the category guide no longer say "no daemon". They say that
a process of your application calls
runtime.run(signal)to run reminders, effects, and queued messages.
-
Back off repeated interrupted-message recovery using
retryDelayMilliseconds. The first claimed attempt recovers immediately; after attempts 2, 3, 4, and onward, the delay callback receives 1, 2, 3, and onward (defaults: 1, 2, 4 seconds, capped at 60 seconds). Total persisted claim attempts, including earlier handler failures, determine the delay. The ready timestamp survives worker replacement and preserves mailbox order while other actors can proceed. Lease loss alone still does not dead-letter a message; handler failure attempt limits, setting names, defaults, and schema stay unchanged. -
Yield to timers and input between busy passes in actor, effect, reminder, and broadcast workers and drain helpers, including repeated lease loss. Timer-driven aborts can stop the runtime between attempts; an instrumentation abort stops before another pass. Synchronous handlers remain non-preemptible.
-
Report
activation.lostwhen a handler failure or rejection cannot commit because its lease expired. These attempts recover with the same fencing and backoff as lost completions, and instrumentation can stop the worker. -
Give overlapping synchronous caller passes separate, reusable workers so cleanup cannot discard another turn's staged effects or other intents. Calls to different actors still execute concurrently; workers are reused once their pass and activation cleanup finish. A caller interrupted by test reset cannot return its stopped worker to the pool and stall subsequent calls.
-
Re-establish actor, effect, reminder, and broadcast process registrations before each pass, including after stale-process cleanup and retention. Explicitly stopped workers stay stopped. No schema or setting changes.
-
Cover caller concurrency and process retention on SQLite, PostgreSQL, and MySQL, with
maxAttempts: 1and both default and single-message pass limits. Keep adapter and worker-role selection explicit in the regression fixtures. -
Drain recovered turns in the failure-recovery demo before checking committed state. Verify completion and allow additional fenced retries when a worker loses its lease on a delayed runner.
-
Advance the database test clock after acquiring commit locks in PostgreSQL and MySQL fencing tests, so runner delays cannot expire the lease before the commit barrier is reached.
- Renew retained activations between short turns and across worker passes, even when synchronous work prevents renewal timers from running.
- Keep an activation with an outstanding message claim discoverable after
lease loss. The next worker pass recovers the message in mailbox order
without a process restart, including with
maxAttempts: 1. Reclaiming incrementsattempt_count; lease loss alone does not create a dead letter. - Run activation lease regressions in PostgreSQL and MySQL CI, reusing and resetting their test tables so repeated runs leave no messages or extra tables. Control the server-database test clocks to avoid runner scheduling delays; SQLite keeps real synchronous waits with a 200 ms lease. Allow 15 seconds for the 34-turn test's database round trips in CI.
- Add
scripts/check-compatibility-fixtures.mjsand a workflow with the nameCompatibility fixtures. The script compares the*.jsonfiles in two directories by name and by bytes. It fails with one line for each file that is missing on one side or different. The workflow runs the script on this repository'scompatibility/directory and on thecompatibility/directory of solid-objects-ruby atmain. It runs on each push tomain, one time each day, and on demand. Before this change, no job compared the shared fixtures. - Track Ruby
solid_objects0.17.4 in the parity ledger, including renewal between short turns and preservation of activation fences for claimed messages.
- Add three problem guides in
docs/guides/. Each one reproduces a failure, shows the simplest fix without an actor, and then shows a tested Solid Objects version with its limits:race-conditions.md: a lost update across anawait, why a mutex in one process does not help, the conditionalUPDATEfix, and anEventSeatsactor whose holds, payment confirmations, and expiry reminders race.persistent-rooms.md: a turn-based room whose state and turn timer survive a restart, the SQL version-column fix, and reconnect that returns the current authorized room state.offline-first.md: browser state in SQLite WASM shared across tabs, and writes queued withsolid-objects/transmitthat a server applies once, in order, after the network returns.
- The examples live in
examples/guides/. Vitest and Chromium tests prove each claim, including two real processes that hold seats at the same time.pnpm run check:documentationrequires each guide to embed its current example files and rejects an em dash in a guide. - Tests resolve
solid-objectstosrc/through the Vitest and TypeScript configuration, so an example that imports the package name runs under test before a build.
- Correct
docs/agents.mdanddocs/fit.md: Solid Objects Pro ships for Node.js as@solidobjects/profrom the private registry at https://solidobjects.pro, beside the Rails gem. The docs said that its Node build was in development.
- Step 12 of
docs/agents.mdnow says plainly that every authorization callback denies by default in the browser too. A worker with noauthorizeMessageand noauthorizeQueryanswers no call. The install step points to the browser policy and toruntime.run(signal), and the checks start with the policy. In an evaluation, two agent answers gave the browser install with no policy after they read that a browser policy is not a security boundary. - A new Chromium test proves that a browser worker with no authorization
callbacks rejects actor calls with
Unauthorized.
docs/agents.mdhas a new step 12 for the browser runtime. It covers when the browser runtime fits, the install with@sqlite.org/sqlite-wasm, the entry points, a module worker example, authorization in the browser, the platform limits, transmit to a Node.js or Rails server, and the checks.examples/browser/draft-worker.jsandexamples/browser/page.jsare the guide example. The Chromium suite runs them: state survives a reload, two tabs share one draft, and the second tab continues after the holder tab closes.pnpm run check:documentationfails when the guide does not embed the current files.- The example keeps short lease settings. With the default settings, a call from the remaining tab timed out while it waited for the lease of the closed tab.
- The documentation link check skips fenced code blocks, so code such as
ref[operation](value)is not read as a link. - Correct the transmit docs:
registerTransmitnever reads the HTTP response, so a server 422 stops retries only when thedelivercallback throwsNonRetryableError. The agent guide shows that callback,docs/api.mdsays so, and a new test proves thatNonRetryableErrorfromdeliverdead-letters the effect after one attempt, where a plainErrorretries. - The page example rejects every waiting and later call when the worker fails to load. Before, a call waited with no end.
- The README names the agent guide at the start of Installation, and the agent
guide says that a reminder changes state only when it runs under
runtime.run(signal), so a query must not compute expiry from the clock. - Correct
docs/agents.md:rejecttakes a code and an options object,this.reject(code, { message }), notthis.reject(code, message). docs/agents.mdnow says that a worker process runs only the actor classes that it knows. For an unregistered class, actor setup fails withUnknownActorType, the worker reportssolid_objects.activation.failed, and the message returns to the queue without a counted attempt. Nothing prints without aninstrumentationcallback. Register each class beforeruntime.run(signal). It also tells agents to install the current release, puts the deny-by-default reminder beside the install step, and lists the API mistakes found in agent-written code.- Claim the Context7 library:
context7.jsonnow carries the libraryurland the maintainerpublic_key. - Add the Context7 refresh workflow. A push to
mainthat changes the README,context7.json,docs/, orexamples/asks Context7 to refresh the index. It uses theCONTEXT7_API_KEYrepository secret.
- Check holds with
Object.hasOwnin the README and guide ticket sale examples. Theinoperator also matched inherited names, so a buyer namedconstructorcould not hold a free ticket, andexpirecould add a ticket that no hold had taken.pnpm run test:packagenow holds a ticket for that buyer. - Override
sharpwith 0.35.5 andsource-map-jswith 1.2.2. Two high advisories reach the repository only through the development tooling:sharpthrough Miniflare andsource-map-jsthrough Vite and PostCSS. The published package does not depend on either. - Name the category in the package metadata and the README: Solid Objects is
a SQL-backed virtual actor library for TypeScript and Node.js. The package
now declares
https://solidobjects.dev/jsas its homepage and adds thevirtual-actorsandactor-modelkeywords. - Add
docs/virtual-actors.md, a category guide with the definition, a small example, fit and poor-fit criteria, comparisons, an Orleans concept map, and separate compatibility statements for the Node runtime, the browser client, the browser runtime, and the Cloudflare backend. - Add
docs/agents.md, a consumer guide for coding agents with setup, authorization, effect idempotency, verification, and troubleshooting steps. - Add Dapr actors, Temporal, and a comparison vocabulary to
docs/comparisons.md. - Add
examples/ticket-sale.ts.pnpm run test:packageruns it against the packed tarball, and the documentation check fails when the guide no longer embeds it. - The documentation check now reads every
docs/*.mdfile instead of a fixed list. - Add
context7.jsonso that Context7 indexes the consumer documentation and skips maintainer files. - Record the clean-install artifact proof in
docs/parity.md. Ruby now has a matchingrake quickstartcheck.
-
Breaking:
solid_objects.activation.startednow fires before the actor'sactivate()hook. Before, it fired after a successful hook. The newsolid_objects.activation.completedevent takes that meaning, andsolid_objects.activation.failedreports a failed hook. Ruby changes the same events. Move a subscriber that readsactivation.startedas a finished activation toactivation.completed. -
Reference
observe()andon()reject a missingonEventcallback withTypeErrorbefore authorization, as Ruby raisesArgumentErrorwithout a block. Tests pin the 1,000-observer limit and observer removal onclose(). The roadmap no longer lists portable observability as future work. -
Add the shared telemetry contract
compatibility/telemetry-events.json. Tests compare the attribute allowlist and the attribute keys of each core SQL event with Ruby. Activation events carryownerId, commit action events carryactivationGeneration,reminder.enqueuedcarriesmessageIdandattempt, and a truncatedmailbox.depthsample reportsdepth: null. -
Port Ruby's query and observable purity tests for each kind of staged work. Name
runtime.deadLetters.effects.retry(id)for a dead transmit, document theactor_diagnosticsauthorization, and remove the unusedcompatibility/ruby.yml. -
Pin personalized payload isolation, staged-work rejection, and configured UTF-8 byte limits with regressions matching Ruby. Share timeout telemetry fixtures for wait reasons and activation ownership; document Ruby's fixes and its yielding SQLite wait handler in the parity ledger.
-
Detect observable replacement of staged work even when the intent count is unchanged. Compare complete intent snapshots around projection evaluation.
-
Preserve reserved JSON keys such as
__proto__as own data properties without changing object prototypes. Include them in size limits, state, arguments, and retained results; share compatibility fixtures with Ruby. -
Record Ruby's matching query/projection purity guards, retained background results, and the intentional 191-character Ruby / 255-character JS reminder name limits in the parity ledger.
-
Preserve transmit staging order within a source message through a persisted effect position, including retries. Schema migration 14 adds
effects.position; runruntime.install()before starting upgraded workers. Legacy rows retain their existing ID tie-break because their original order cannot be recovered. -
Breaking: reject explicit null transmit arguments, matching Ruby; omission still defaults to an empty object. Expand the shared wire fixtures and adapter ordering tests.
-
Refresh the parity ledger for authorized message reads and retained background results, bulk redrive/audit records, reminder cancellation, and automatic wake-up.
-
Patch the Cloudflare development tooling's Undici dependency to 7.29.1 to resolve the high-severity WebSocket and TLS advisories reported by CI.
-
Add portable observability envelopes, metric definitions, actor observers, and bounded authorization-aware diagnostics across SQL runtimes and the Durable Objects host. Isolate failing instrumentation and error loggers from actor work.
-
Find SQLite effect recovery candidates through the processing effects.
sqlite_stat1records only the average row count for each effect status. When most effects are complete, SQLite estimated thatstatus = 'processing'matched most of the effects table. It then read every recovery row in index order, on each effect poll. On SQLite the status test now carrieslikelihood(..., 0.000001), so the plan starts from theeffects_pollindex. The PostgreSQL and MySQL queries do not change. Ruby 0.16.1 has the same fix. -
Ruby 0.16.1 also fixes the SQLite join order of its claimed-message scan. The JavaScript claim path has no such scan, and SQLite already starts
recoverExpiredClaimsfrom the claimed messages, so that fix needs no JavaScript change. -
Build before
npm publishreads the manifest. npm validatesbinagainst the working tree beforeprepackproducesdist, so every release loggedNo bin file found at dist/executable.jstwice. The published package was always correct, andnpx solid-objectshas always worked, but a warning that says the binary is missing is a poor thing to print while publishing one. The publish job builds first and publishes with--ignore-scripts, so the build runs once rather than twice. -
Check the
binentry the manifest declares rather than a hard-coded path.check-package.mjsasserteddist/executable.jsdirectly, so renaming the build output would have shipped abinthat resolves to nothing while the check still passed.
-
Find a message whose reference a caller lost.
runtime.findBy({ requestId })answers a request id, which is unique across the table, andreference.findBy({ idempotencyKey })answers a key, which is unique per actor, so the receiver supplies the scope the key needs. Naming neither key, naming both, or naming an idempotency key without a reference throws. -
Authorize every lookup with the hook the original call ran, against the stored operation and arguments, because a request id is not a capability. An absent row, an actor this process no longer registers, and a caller the policy refuses all return
undefined, so a lookup cannot be used to ask whether a request id exists. -
Add
messageReference.outcome(), which reports the status, the result, the persisted error, the rejection, and the attempt count, so a terminal failure answers as well as a success. Every value is frozen, so a caller cannot mutate a durable result it read. The error carriesnameandmessage; this runtime has never persisted a stack, which parity now records against Ruby'sbacktrace. -
Add schema version 12: a unique index on
messages.request_id. The table had onlyUNIQUE (actor_type, actor_id, request_id), which cannot serve a lookup that names the request id alone. -
Tell a pruned message from one that never existed. An actor remembers the idempotency keys of its own finished turns, the way an Orleans grain keeps its deduplication history in grain state, so the memory needs no second store and no second write.
reference.findBy({ idempotencyKey })throwsMessagePrunedfor a key the actor remembers and whose message retention removed, and still returnsundefinedfor a key no caller ever sent. The An actor remembers the operation beside each key, so the pruned answer runs the same hook against the same operation a lookup of the surviving row would, and a caller the policy refuses readsundefinedfor both. Gating it onsnapshotwould have told a caller who may read state, but not the operation, that the operation had run. The Durable Objects lookup no longer demands a synthetic__lookupMessage__query in addition, which a policy that allows only declared queries refused.retainedIdempotencyKeysbounds the memory and defaults to 64 keys for each actor. A lookup by request id cannot make the distinction, because the runtime generates a request id and no actor remembers one.retainedIdempotencyKeysBytesbounds the serialized memory as well, 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, so a key long enough to fill the limit by itself is never remembered. -
Add schema version 13:
instances.completed_idempotency_keys. The doctor now reports the column as missing when it is not installed. -
Implement
findByandmessageOutcomeon the Durable Objects runtime, whichActorRuntimerequired and the backend did not supply, sopnpm run checkandpnpm run buildboth failed. A Durable Object indexes only its own messages, soruntime.findBy({ requestId })without a reference raisesUnsupportedCapability; every other form works. A lookup that the policy refuses answersundefinedthere too. The Durable Objectslookupgates onauthorizeQuerybefore it reads, which would otherwise have thrownUnauthorizedwhere the SQL runtime answers absent, and a lookup that threw where it was refused is a way to ask whether a key exists. -
Read the expected schema migration list from
SCHEMA_VERSIONSin the doctor and in the tests that assert it, rather than from four hand-copied lists. -
Retry a dead effect or broadcast.
runtime.deadLetterskeeps its message meaning and answerseffectsandbroadcasts, so the kind rides on the receiver.retryreturns a dead row to pending with a zero attempt count and no claim, keeps its id so a deduplicating handler sees the same key, and acts only on a dead row, so a second press cannot double-enqueue. -
Redrive a whole scope.
redriveopens a durable task, returns at once, and is idempotent over its scope and filters, which a dashboard button needs. A unique index on the active scope enforces that in the database.runtime.run()advances one bounded batch per pass, so a redrive never holds a transaction longer than one batch.runtime.redrivesreads tasks back, andtask.cancel()stops one and leaves the rows it already moved. A redrive moves what was dead when it started, so a still-broken handler cannot make it run forever. -
Record who pressed what. Every retry and every redrive transition writes one row to
solid_objects_administration_events. The identity comes from the authorization context through a newadministrationIdentityoption. -
Add
redriveBatchSize, which defaults to 100, andredriveBatchPauseMilliseconds, which defaults to 50. -
Add schema version 11: two tables,
solid_objects_administration_eventsandsolid_objects_redrives, and afailed_at_msstamp on effects and broadcasts, which a redrive filters on. Existing dead rows take their availability stamp. -
Select a wake-up adapter automatically.
wakeUpnow takes a name or an adapter, asconfig.cache_storedoes in Rails, and defaults to"automatic". Selection prefersSOLID_OBJECTS_REDIS_URL, then PostgreSQL notifications, then polling."in_process"opts out, and an unknown name throws rather than polls quietly. -
PostgreSQL applications that configure nothing now use notifications. They gain cross-process wake-up, one dedicated listening client, and one
NOTIFYper commit. SetwakeUp: "in_process"to keep polling. -
Prove the PostgreSQL notification path before selecting it, because
LISTENdoes not survive a transaction pooler such as PgBouncer. Selection listens on a probe channel, sends oneNOTIFYfrom a second connection, and waits up to two seconds for it to arrive. A probe that does not deliver falls back to polling and warns once. -
Report the resolved choice.
runtime.wakeUpCapability()names the adapter, whether it crosses processes, its measured floor, and why it was chosen. The doctor reports it as awakeUpcheck, and the polling-only warning now fires on what was installed rather than on whether a setting was set. -
Keep the capability a configured adapter reports about itself. A configured
InProcessWakeUpAdapternow reportsin_processand warns, rather than claim that it crosses processes. -
Poll rather than pretend when a requested adapter cannot be built.
wakeUp: "postgresql"on a database with no notification channel,wakeUp: "redis"withoutSOLID_OBJECTS_REDIS_URL, and a Redis URL without theredispackage each logsolid_objects.wake_up.unavailableonce and report the reason in the capability, so the doctor warns rather than claim a cross-process wake-up that cannot happen. -
Select once per runtime.
runtime.wakeUpAdapter()memoises the selection, so callers that race for the first use share one probe rather than run one each. -
Add reminder reading.
reminder()returns one armed alarm as aScheduledReminder, andreminders()lists every key of one operation. Both apply the intents staged so far in the turn, so a read agrees with what the commit will write. Reading works on the SQL backends and on Durable Objects. -
Leave a one-shot reminder that already fired out of
reminder()andreminders(). Its row stays ascompleted, so a next-run lookup reported an old time rather than nothing, and an existence check refused to re-arm an alarm that could never fire again. -
Refuse an unknown operation in
unschedule()andunscheduleAll().schedule()already threwUnknownOperationfor one, so a typo cancelled nothing quietly and left a recurring reminder running. -
Add reminder cancellation.
unschedule()removes one alarm by operation, by operation and key, or by the handleschedule()now returns.unscheduleAll()removes every key of one operation. Both stage an intent beside the schedules, so they apply in the order the turn called them, commit with the state change that decided them, and cancel nothing when a turn throws. Cancellation works on the SQL backends and on Durable Objects. A cancellation that lands on an occurrence the scheduler claimed but has not yet enqueued pre-empts it, and the scheduler continues rather than failing. -
schedule()now returns aReminderHandle({ name: string }) instead ofvoid. A handle is a plain object, so it survives in actor state and still cancels after a deactivation. Code that assigned the result tovoidneeds updating, asemitrequired in 0.15.0.
- Stop the deadlock between concurrent callers that create the same actor from
a transaction of their own. MySQL turns a portable conflict clause into
INSERT IGNORE, which keeps a shared lock on the identity index, and the mailbox then asked to upgrade that lock. It now reads the winning row in shared mode, selects only its id so the read stays inside the index, and locks the row by its primary key. - Read the instance without a lock before the insert, on every database, and take the row lock by primary key rather than by actor type and actor id.
- Index opt-in instance expiration by actor type and update time so pruning can find expired instances without scanning retained history. Install schema migration 10 before running this version. Retention policies, timestamps, pending-work protections, and deletion order are unchanged.
- Report transient process-heartbeat errors and keep retrying while effects run, so one failed update cannot permanently disable owner freshness maintenance.
- Return a stable
EffectHandlefrom everyemit. Overrides/wrappers must return the handle; code expectingvoid/undefinedneeds migration. - Add SQL effect recovery coordination with
onRecovery, optionalonStatus, stagedrequestEffectRecovery, and an extendingrecoveryTimeoutMilliseconds. Retirement and durable callbacks share the claim-locking transaction. Export typed outcome constants and callback envelopes. Install schema migration 9 before running this version. Cloudflare returns emit handles and rejects the unsupported process-heartbeat recovery options before commit.
- Export effect failure/success payload types and
SerializedErrorfrom the root and core entry points. Check SQL and Cloudflare callback construction against the same contracts without changing the delivered messages. - Infer operation names and arguments for schedule/transmit calls inside actor
methods and check literal effect callback names. Export
ScheduledOperationsForandEffectOptions; retain explicit dynamic-name paths and runtime validation. Subclasses with explicit legacyScheduledOperationsreturn annotations onscheduleortransmitmust update their override signatures to match the generic Actor methods.
- Hold the actor instance and message claim locks through every fenced commit on PostgreSQL and MySQL. Lease recovery and stale-process cleanup revalidate those rows after waiting, so they cannot requeue a message that completed in the meantime.
- Read SQL message results and completion status in one statement so concurrent
completion cannot return a stale
nullresult. - Add
solid-objects/coreand 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.
- Poll effects, reminders, and broadcasts through ordered indexes installed by
schema migration 8. PostgreSQL and MySQL claim one row with
FOR UPDATE SKIP LOCKED; candidate probes avoid joins and load actor identity by primary key after the claim. SQLite keeps its serialized transaction path. The broadcast revision guard has its own(instance_id, state_revision, status)index instead of rescanning the outbox for every candidate. Claim transactions use read committed isolation on PostgreSQL and MySQL so preceding recovery work cannot turn row skipping into InnoDB gap-lock contention. - Replace bulk broadcast and reminder recovery updates with separate available and stale probes. Compare category heads without locking them, lock only the oldest candidate by primary key, and retry past work already locked by another claimant. This preserves global delivery order, concurrent progress, per-instance broadcast revision order, stale-process recovery, and at-least-once delivery.
- On 50,000 production-shaped rows, SQLite, PostgreSQL 18, and MySQL 8.4 all move from scans and explicit sorts to ordered index probes. The worst SQLite broadcast probe fell from 944 ms to 0.018 ms; PostgreSQL's probes finish in 0.02-0.09 ms and MySQL's in 0.10-1.2 ms.
- Record the Ruby state warning in the parity ledger. The row said the Ruby gem
has no soft threshold. It carries the same
solid_objects.state.largeevent and the same 5 MB hard default from0.14.3, aswarn_state_bytes. Its threshold defaults to 64 KB rather than 128 KB, because the measured Ruby curve falls sooner: the gem keeps 55% of its empty-state throughput at 13 KB of state, where this package keeps 98% at 16 KB. Documentation only; no runtime change.
-
Cut the per-turn state traversals from eight to four. The runtime built the whole state image eight times for each committed operation, and nine times for a query, where two are necessary. The commit path now passes the image it already holds to the observables guard, and the query check reads the committed image instead of taking its own. The guard is unchanged: it still reads the state after
observables()returns, because only that read sees a mutation. -
Compute the default state once for each registered actor class. Every
sendand every hydration constructed a throwaway actor and serialized its full default state. The constructor must not depend on external state, so one cached image per validated definition is correct. Each caller receives a detached copy. -
Stop building a string that
normalizeJsondiscards. It calledJSON.stringifyon every value, then used the result only when a byte limit was given.actorState,deepCopy, andstableJsonall pass no limit. -
Measured on an Apple M5 with SQLite: 1.2x throughput at 0 KB of state, 1.3x at 16 KB, 1.6x at 128 KB, and 2.1x at 1 MB. See Large state.
-
Add
warnStateBytes, a soft threshold that defaults to 128 KB. A commit above it reports onesolid_objects.state.largeinstrumentation event with the actor type, the actor ID, the byte count, and the threshold. The runtime reports it only after the commit succeeds, so a turn that rolls back stays silent. The event holds no application state, and the runtime measures the size only when aninstrumentationcallback is configured.maxStateByteskeeps its 5 MB hard default, which fails the turn. Throughput at that size is about one operation per second, so the warning names the constraint before an application meets it. -
Add a
large-statebenchmark scenario,pnpm run benchmark:large-state, that reports operations per second at 0 KB, 16 KB, 128 KB, and 1 MB, and document the measured curve indocs/state-and-lifecycle.md. -
Align the use-case claims with the Ruby gem. The README table sold per-key rate limits, while the Ruby fit guide called a rate limiter an anti-pattern. Both projects now draw one line: a low-rate quota that a reminder refills fits, because each check is one durable ordered message, and a limiter that every request touches does not.
-
Point the high-QPS reader at Solid Objects Pro from the rate-limit sentence in the README and
docs/fit.md, and name what it adds for that shape: grouped commits and ephemeral operations. The README states that it ships for the Rails gem today and that the Node build is in development. -
Move the early-release caveat off the first screen. It is now a
Statussection at the end of the README, with a table-of-contents entry, which matches where the Ruby gem keeps the same statement. The first screen keeps the transaction caveat, because that one changes whether a reader should install anything. -
State the workflow limit next to the workflow row instead of only in
docs/fit.md. A workflow fits when one entity owns the mutable state and its mailbox holds the step order. A durable execution engine that replays named steps from a step log is a different tool.
- 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.locksin the browser, then argues scope rather than discipline: anyexpiresAtorscheduledAtcolumn 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.
- 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
customElementsorHTMLElementin 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.
- State that background pickup needs
runtime.run(signal)(#22). The README's programming-model example works without it because the caller's own path executes the call, and nothing on that page said that a process which installs and then waits claims nothing. An external prober built a two-process harness from the README and read the unclaimed messages as stranded. The README anddocs/operations.mdnow state it, andtest/background-pickup.test.tspins it: a sent message readsreadyafterinstall(), andcompletedoncerun(signal)starts the roles. - Build the same example with
configure()and address the actor asCart.ref("cart-123"), matching every other reference example in the documentation.createRuntime()deliberately leaves the process default unset, so the static form needsconfigure(). - Add
examples/at-least-onceandpnpm run test:at-least-once(#23): an executable proof that the at-least-once clause fires and that the documented remedy absorbs it. An effect worker crashes between the external sink write and the acknowledgement; after restart the sink reads 2 with deduplication off, and 1 when a guard on the stable effect id is in place. The state commit happens exactly once in both runs, and both deliveries carry the same effect id. CI runs the demo alongside the recovery demo.
- Add
solid-objects/signals, live signals on actor references (#24). One side-effect import enablesreference.live: read-only signals on the proposed standard JavaScript signals API (signal-polyfill, a new optional peer dependency) that subscribe through an in-processruntime.realtimesession when first watched and unsubscribe after a linger when the last watcher leaves. Value-broadcast observables feed named signals;live.snapshotre-fetches the authorized snapshot on each accepted envelope;live.payloads.<name>carries personalized payload projections under their independent revision fences; stale revisions are fenced. Works in Node and in the browser runtime.
-
Add
solid-objects/database/shared-sqlite-wasm, the transparent multi-tab database. Every tab runs an ordinaryconfigure -> install -> refflow against the same shared database; the adapter elects one holder per origin with the Web Locks API, sends every other tab's SQL over aBroadcastChannelsession to the holder, and fails over onto the same OPFS state when the holder's tab dies. The runtime's leases and fencing arbitrate the tabs' workers exactly as they arbitrate Node processes. A Playwright test proves plain actor references incrementing one durable counter from two tabs with failover. -
Add
solid-objects/browser/tab-host, the multi-tab host that completes milestone M3 of the in-browser runtime plan (#17). Every tab starts a candidate host; the Web Locks API elects one leader per origin, and only the leader opens the database and runs the runtime. Tabs invoke actors through aBroadcastChannelclient that retries with idempotent request ids. When the leader's tab dies, the lock releases, the next host promotes, and the runtime continues from the same OPFS state. A Playwright test proves shared state across two tabs and failover after the leader closes. -
Share the transmit wire contract with the Ruby gem. The golden fixture file
compatibility/transmit-envelopes.jsonis committed to both repositories with a consuming test on each side;receiveTransmitEnvelopenow defaults a missingargumentsto an empty object, matching the Ruby ingest and the staging side. -
Add
solid-objects/transmit, milestone M4 of the plan. An actor stages a transmit intent withthis.transmit().operation(arguments)(or withemit(TRANSMIT_EFFECT, ...)for a different target) in the same transaction as its state change.registerTransmitdrains the outbox with at-least-once delivery and per-actor order (an ordered drain up to the claimed effect's mailbox sequence), andreceiveTransmitEnvelopegives the server an idempotent ingest keyed on the effect id. Vitest covers order under transmit failures, replay deduplication, and recovery after an offline period; a Playwright test drains a browser outbox into the Node server runtime. -
Change
TurnContextStoreto synchronous scoping. The store no longer stays set acrossawaitboundaries, so an open transaction scope cannot leak into interleaved tasks and trip the inside-transaction guards. In the browser those guards are best-effort; Node keeps fullAsyncLocalStoragesemantics. -
Retry OPFS SAH pool acquisition. The upstream module caches a failed initialization; the adapter now passes
forceReinitIfPreviouslyFailed, so a new leader can claim the pool after the old tab dies. -
Add a platform seam for async context propagation (
src/platform/context-store.ts). Shared modules no longer importnode:async_hooksdirectly. Node entry points register anAsyncLocalStoragefactory. ATurnContextStoregives a browser host a turn-scoped store for serialized actor turns. This is milestone M1 of the in-browser runtime plan (#17). -
Route UUID generation through
src/platform/uuid.ts, which uses the standardcrypto.randomUUID(). Shared modules no longer importnode:crypto. -
Add
check:browser-importstopnpm run check. The script walks the import graph of the browser-safe modules and fails when anode:module or a server-only driver reaches that graph. -
Add
solid-objects/browser/host, the entry point for a runtime host inside a browser worker. An import registers the browser platform: a turn-scoped context store and a browser host identity. The module re-exports the core runtime API and the WASM adapter. A Playwright test runs the full runtime in a Chromium module worker and proves durable actor state across a page reload. This is the first stage of milestone M3 (#17). -
Replace the
Buffer.byteLengthpayload size check withTextEncoder, so serialization works without the NodeBufferglobal. -
Route the process identity (hostname, process id, runtime version) through
src/platform/host-identity.ts. The repository no longer importsnode:osor readsprocess.piddirectly. -
Add
solid-objects/database/sqlite-wasm, a browser-safeDatabaseadapter on@sqlite.org/sqlite-wasm(an optional peer dependency). The full runtime passes its round-trip test against this adapter in Node, and a Playwright test proves transactions, rollback, and OPFS persistence across a page reload in Chromium. This is milestone M2 of the in-browser runtime plan (#17). -
Add
runtime.enqueueInternalMessage()andruntime.enqueueInternalMessageInTransaction(connection, options), public entry points for a host package to enqueue aninternal-delivery-mode actor message without going through user authorization. The transaction-scoped variant accepts a caller-suppliedDatabaseConnectionso the enqueue can commit atomically alongside other writes in the same transaction; call the newruntime.announceInternalMessage(message)after that transaction commits to wake worker roles the same way a normal enqueue does. -
Add
runtime.snapshotWithIncarnation(reference), returning the same authorized fields assnapshot()alongside the read instance'sinstanceId,revision, andcreatedAtMs, computed from one shared read.instanceIdis a random UUID, not a monotonically increasing value, so a caller that needs to detect actor recreation (a new incarnation superseding an old one, regardless of revision) should fence oncreatedAtMsrather than comparinginstanceIdvalues directly.createdAtMsorders incarnations at millisecond granularity; destroying and recreating the same actor identity within the same millisecond produces two incarnations a caller cannot order bycreatedAtMsalone. Seedocs/correctness.md.
- Lower the supported Node.js floor from 24.15.0 to 24.4.0. Node.js 24.4.0 is
the first release that accepts
readBigIntson theDatabaseSyncconstructor, which the SQLite adapter needs to read 64-bit integers without losing precision. Node.js 24.0.0 through 24.3.x ignore the option, and the effect recovery and transaction retry tests fail there. A new CI job runs the default suite, the build, the packaged artifact smoke test, and the recovery demo on the floor. - Record that
node:sqlitestays experimental until Node.js 24.15.0 and prints a warning on stderr before it. - Make the failure-recovery demo's serialization proof count messages rather
than executions. A worker that loses its lease mid-operation leaves the
replacement to execute the same message again, which is the at-least-once
contract, so the proof failed on slower machines for behaviour it documents
elsewhere. Each serialization event now carries its attempt and process, so a
start pairs with its own finish instead of with whichever finish came next.
The proof asserts that every start has its own finish, that exactly the two
sent messages ran, and that the surviving attempt of each message never
overlaps another message's surviving attempt; a superseded attempt may
overlap anything, because it keeps running until it notices the lost lease and
its write is fenced out. The committed state check is unchanged, and the demo
reports the executions it saw.
assertSerializedExecutionmoved into its own module with unit coverage for the clean, retried, superseded-overlap, still-running-replacement, unexplained-overlap, boundary, unfinished, unmatched-finish, double-start, restart-after-finish, and lost-message cases.
- Accept a
keyonschedule, naming a reminder for the item it is waiting on rather than for its operation, so one actor can hold an alarm per queued item. Scheduling the same key again moves that item's alarm and leaves the others alone. Without a key the name is still the operation, so existing reminders keep their names and their coalescing behaviour. Adds a nullablemessage_operationcolumn to the reminders table, left null on existing rows. The composed name is bounded by the 255 characters MySQL holds it in, checked on the name rather than the key alone. - Add an authorized
runtime.administration.processes()query for inspecting live and stale process rows through the runtime's database adapter. - Document rolling-deployment overlap as a reason for the polling-only warning.
- Back idle actor, effect, reminder, and broadcast polling off exponentially from the configured fast interval to a new one-second idle ceiling. Any processed work or wake-up resets the role immediately, and actor polling remains capped by the lease-renewal interval.
- Expose each role's current polling interval and emit
solid_objects.polling.interval_changedinstrumentation for every idle, work, and wake-up transition. - Warn once when live processes share the database without a configured cross-process wake-up adapter.
- Preserve older custom wake-up adapters that return
void; returntruefor notifications andfalsefor timeouts from the built-in PostgreSQL, Redis, and in-process adapters so adaptive polling can distinguish them. - Add a reproducible four-role SQLite idle benchmark.
- Behavior change:
pollingIntervalMillisecondsis now the fast interval after activity, not a constant idle cadence. Existing explicit values back off toidlePollingIntervalMilliseconds, which defaults to1_000. Set both options to the same value to preserve a fixed cadence.
-
Replace the exhaustive README with an outcome-first introduction, explicit fit and correctness boundaries, sourced comparisons, and factual design provenance.
-
Add a packaged SQLite quickstart, a clean-install tarball smoke test, and a deterministic multi-process crash and fencing demonstration.
-
Add a reproducible benchmark harness, record locally observed SQLite, PostgreSQL 18, and MySQL 8.4 measurements, and label other configurations as unmeasured.
-
Add support, contribution, and security policies plus stronger local Markdown link validation.
-
Breaking: make unwrapped observables invalidation-only by default. They continue to detect changes and refresh dependent components without storing or sending their values. Wrap a projection in
broadcastValue()to share its scalar value with every authorized actor subscriber. -
Add
broadcastInvalidation()so actors can drive granular component refresh without persisting or sending the observable value. -
Add
broadcastValue()for the explicit value-bearing observable contract. -
Extend durable and browser invalidation envelopes with invalidation-only observable names.
-
Add the framework-neutral
solid-objects/weboperator dashboard with Fetch and Node/Connect mounting, runtime statistics, instance and mailbox detail, reminder/effect/broadcast/dead-letter/process views, filtering, paging, instance pause/resume, and idempotent dead-letter retry. -
Deny dashboard data by default through route-specific administration policy, require session-backed masked CSRF tokens for actions, escape stored values, and serve a strict content security policy.
-
Add immutable dashboard extension routes, tabs, renderer overrides, and middleware, plus configurable Chart.js dashboards and live statistics.
-
Preserve downstream request bodies when the Node/Connect dashboard adapter cascades a request outside its mount path.
-
Add authorized and public read-only dashboard access modes that omit mutation controls, reject POST actions, and require no CSRF session for public demos.
-
Refocus the README and package metadata on native Node.js concurrency, realtime state synchronization, and database-backed infrastructure.
- Accept camelCase rejection codes and fail malformed codes immediately with
non-retryable
InvalidRejectionCodediagnostics. - Breaking: include the originally staged
argumentsin effect success and failure callback payloads so actors can correlate concurrent effects. - Add
runtime.testing.runDueReminders({ now })for deterministic reminder tests without database writes or sleeps. - Document adapter value mapping, readonly snapshot helpers, payload type-alias
requirements, realtime observable exposure, message-reference authorization,
durable
voidresults, retry backoff in tests, and administration result fields.
-
Document the complete public API, runtime and adapter configuration, state-evolution lifecycle, and error-recovery contracts.
-
Complete the Ruby 0.12.0 spiritual-parity pass with Node-native runtime, administration, realtime, browser, and multi-adapter equivalents.
-
Return claimed messages to ready membership without consuming an attempt when actor hydration, state migration, or activation fails before execution begins.
-
Keep guarded application databases read-only during commit actions so the supplied fenced transaction connection is the only atomic write path.
-
Persist the start of graceful runtime-role shutdown and expose its timestamp so operators can distinguish actively draining processes from dead owners.
-
Release actor, message, effect, reminder, and broadcast ownership atomically during graceful shutdown and recover stale draining processes as well.
-
Persist hostname, host PID, Node version, and Solid Objects version for every runtime role and expose them through immutable process administration reads.
-
Make typed snapshots return persisted fields and getter values from one committed state image, rejecting getters that mutate state or stage work.
-
Probe configured authorization policies with a neutral context in the doctor and warn when sensitive access is open or a policy cannot evaluate safely.
-
Align effect and commit-action context with their durable source message and activation fence, using immutable TypeScript contracts.
-
Bound runtime shutdown with a configurable deadline and report components that do not cooperate with cancellation or stop before it expires.
-
Separate generated request identity from caller-supplied idempotency keys and expose both with enqueue time through immutable actor message context.
-
Preserve isolated runtime ownership across operation, lifecycle, observable, and payload callbacks while keeping lifecycle callbacks message-free.
-
Retry transient SQLite writer acquisition with bounded capped backoff without replaying transaction callbacks or extending synchronous deadlines.
-
Expose concise
configure()and isolatedcreateRuntime()entry points; isolated actor turns resolve references from their executing runtime. -
Raise structured
MessageFailederrors for terminal operations andActorDestroyedwhen an authorized incarnation disappears during a wait. -
Add deny-by-default dead-letter inspection and idempotent durable retry.
-
Add sequential schema migrations and upgrade version-one SQLite databases.
-
Add bounded, authorization-gated reconciliation reads for actor lifecycle repair.
-
Add previewable bounded retention for messages, stopped processes, and opt-in actor instances.
-
Add structured installation diagnostics and a targeted durable round trip.
-
Add deterministic runtime draining and explicit database reset helpers for tests.
-
Add isolated structured lifecycle instrumentation with metadata-only events.
-
Add reminder replacement events and authorized inspection and resume APIs.
-
Add deny-by-default transport-neutral realtime subscriptions with committed replay and revision fencing.
-
Add injectable generation-based wake-up signals with a process-local default.
-
Add supervised role replacement through original factories with capped restart backoff.
-
Add authorized process inspection and atomic stale-owner cleanup.
-
Add a JSON CLI for runtime startup, diagnostics, administration, and explicit retention pruning.
-
Add bounded multi-turn activation passes with database-ordered hot-actor fairness.
-
Add an opt-in application-database facade that rejects direct writes during actor execution while keeping registered commit actions writable.
-
Add typed subscriber-specific realtime payloads with query authorization, committed-state projection, failure isolation, and independent revision fencing.
-
Add PostgreSQL 14+ through the optional
pgdriver with pooled transactions, row-locked sequence allocation, portable schema and set queries, diagnostics, and real-server integration coverage. -
Add opt-in role-specific PostgreSQL wake-ups with one event-driven listener per runtime and polling as the correctness fallback.
-
Add a typed browser component registry with keyed dependencies, replace/morph strategies, batch coalescing, cancellation, and per-target revision fencing.
-
Add Chromium QA for native WebSocket subscription replay, realtime fences, personalized payloads, component batching, and request cancellation.
-
Add optional Redis Pub/Sub wake-ups with role-specific channels, separate lazy connections, bounded failure handling, and real-server integration QA.
-
Add MySQL 8.0+ through the optional
mysql2driver with pooled transactions, an InnoDB schema, scoped enqueue deadlock recovery, diagnostics, and real-server coverage across MySQL 8.0 and 8.4. -
Add hydrated idle activation reuse with renewable fenced leases, protected async lifecycle hooks, state restoration after failed turns, and release on timeout, fairness yield, lease loss, and shutdown.
-
Bound activation passes by configurable elapsed time as well as message count so slow hot actors yield workers fairly.
-
Add independent supervised schedulers for expired message and process retention and stale process recovery, with disable switches and bounded failure backoff.
-
Bound synchronous database pool, query, and lock waits across SQLite, PostgreSQL, and MySQL; distinguish an uncommitted enqueue timeout from a recoverable durable wait timeout; and report structured database, activation, and mailbox blockers.
-
Reject committed calls and message waits inside an ambient Solid Objects database transaction before they can self-deadlock.
-
Scan a bounded set of ready actor candidates so a lost lease race does not make a worker report idle while other actors are ready.
-
Add ordinary TypeScript actor classes with inferred, typed references.
-
Add committed calls, asynchronous sends, actor-to-actor delivery, reminders, effects, commit actions, snapshots, destruction, and explicit observables.
-
Add a SQLite durable mailbox with ordered turns, idempotency, retries, leases, fencing, dead letters, process heartbeats, and stale-claim recovery.
-
Add supervised workers for actor turns, effects, reminders, and broadcasts.
-
Add a Node-free browser client for versioned observable invalidations.
-
Deny public actor operations and destruction by default, and reauthorize durable message status, result, and wait reads.
-
Use options objects for library APIs with more than two inputs and enforce the convention during type checking.
-
Name invoked actor methods
operationthroughout the API and persistence, and name message submission semanticsdelivery_mode. -
Support SQLite, PostgreSQL, and MySQL database adapters in the initial release.