v0.12.0
The addressability release. Things that existed but could not be named now can be: a shard is a real actor with a real ActorRef, an entity has a location-transparent handle, singletons and sharded types carry typed keys declared on the actor class, and framework actors moved out of /user into grouped /system paths. Underneath that, the DevTools suite (an embeddable web UI for a running system), five more persistence backends on a new relational base layer, and a broad correctness pass over the 2026 audit. Pre-1.0 — this minor carries breaking changes; see below.
🚀 New features
Cluster addressing
- A shard is a real actor:
Region → Shard → Entity(#511) — entities are grandchildren of the region at/user/sharding-<type>/shard-<n>/entity-<id>. TheShardactor owns the entity lifecycle; routing, buffering and passivation policy stay in the region, which is what keepsmaxEntitiesmeaning "per node". Handoff is now simply "stop the shard", soHandOffCompletefinally means what it says. See Breaking changes for the cost. - Shard introspection (#151) —
ClusterSharding.shards(typeName)answers cluster-wide with aShardInfoper placed shard (id, hosting node, region path, live entity count, locality, and a usableref);shardRefFor(typeName, shardId)hands back one shard's ref and allocates it if it had no home. The coordinator fansGetShardRegionStatsout to the registered regions and joins the answers againstshardHome; a region that misses the deadline contributes0rather than failing the call. NewStartEntity/GetShardStatsshard commands. ClusterSharding.entityRefFor(typeName, entityId)(#512) — a location-transparent handle to a single entity. It wraps each message in an id-addressed envelope the region routes without consultingextractEntityId, so the message type no longer has to know how it is routed. Synchronous (the shard ishash(entityId) % numShards), and a proxy region is enough to hand one out.SingletonKeyandShardKey— typed, class-declared identities (#523) — declared as a static on the actor itself, tying thetypeNameand the message type together the wayServiceKeyalready did for the Receptionist.ShardKeycarries theextractEntityIdalongside the name;SingletonKeycarries an optional role. Identity is the name alone in both cases, and options still override.cluster.singleton(#523) — the facade mirroringcluster.sharding, plusClusterSingleton.ref(key)for a singleton ref on a node that never hosts it (the counterpart tostartProxy),stop(key),managerFor(key)andisStarted(key).
Persistence
- Relational base layer (#389) — a new SQL backend is a
SqlDialect+ aSqlPooladapter + three thin subclasses. - Five more backends (#438) — MongoDB, DynamoDB, Microsoft SQL Server, libSQL / Turso and Cloudflare D1, each with journal + snapshot + durable-state. CockroachDB and YugabyteDB certified on the Postgres stores (#401).
SqliteDurableStateStore, SQLite persistence on Deno,LazyStore,close?()onDurableStateStore,SqlDialect.rowLimit(count), and by-tag projections that accept a fullTagFilter.PersistenceExtension.configure({ journal?, snapshotStore? }), plusCassandraJournalOptions.withLightweightTransactions()/.withSerialConsistency().
DevTools
- An embeddable web UI for a running system (#445) — seven panels on one versioned tap protocol, behind a
./devtoolsexport. Absorbs the separately-tracked live cluster visualizer (#204).--devtools-hostmakes the examples' bind interface configurable.
Core & observability
- Actor lifecycle events on the
EventStream(which now accepts abstract classes as channels),ActorSystem.startedAtMs,CoordinatedShutdown.removeTask(phase, name),Props.asInternal(),MetricsExtension.disable(),replayState(),TeeTracer, a boundedRecordingTracerwith monotonic timings, andRetryOptions.sleep.
Tooling & CI
- Benchmarks are gated (#506) — a new
benchmarksworkflow runstypecheck:bench(a benchmarks-only compile) andbench:smoke(every suite, one unwarmed iteration, ~30 s). Nothing looked atbenchmarks/before, so asrc/change that orphaned one was invisible. - Package-health CI — publint, arethetypeswrong and knip; plus
"sideEffects": falseand a memoizedActorPath.toString(). - The committed DevTools UI bundle is CI-gated (#521) by a
source-hash, not a byte diff — bundle bytes vary with the OS and Bun release that produced them, so a byte diff is not a staleness signal.
⚠️ Breaking changes (pre-1.0)
- A shard is a real actor (#511) — anything that resolved an entity by path must insert the
shard-<n>segment (hashShardId(entityId, numShards));ActorPath.parentof an entity is now its shard. This costs throughput and the number is not small: every message to a local entity takes one extra node-local hop, measured at ~40k → ~29k ask/s on one node (−28 %, +9 µs) and ~42k → ~25k on two nodes (−41 %, +16 µs). If you route hot-path traffic through a region and relied on the old numbers, this is the change that moved them. - Framework actors moved from
/userto grouped/systempaths (#509) — the DevTools hub, shard regions and coordinators, the singleton manager, the pub-sub mediator, the receptionist, DistributedData, reliable-delivery controllers and projections all left/user, and dropped the name prefix that only existed to stop a dozen unrelated actors colliding as flat siblings:/user/devtools-hub→/system/devtools/hub, and so on. ClusterSingleton.start()returns anActorRef;SingletonHandleis gone (#523) —system.extension(ClusterSingletonId).start(cluster, options)→cluster.singleton.start(options);handle.proxy.tell(m)→singletonRef.tell(m);handle.stop()→cluster.singleton.stop(key);handle.manager→cluster.singleton.managerFor(key).withTypeName/withPropsare unchanged. One behaviour change:stop()on the returned ref is now a warning no-op — it is the proxy, andActorRef.stop()means "PoisonPill the target" everywhere else, which would have killed whatever the host was running.- Abbreviations spelled out across all identifiers — type, class, file, method, field, generic-parameter and local names use full words (
Command/Message/Acknowledgment/Request/Response/Function/Context/Connection, no moreCmd/Msg/Ack/Req/Res/Fn/Ctx/Conn). Public surface is affected: generic parameters (PersistentActor<Command, Event, State>),Scheduler.scheduleOnceFunction/scheduleAtFixedRateFunction, exported types (HealthCheckFunction), config fields (maxMessages/maxAcknowledgmentPending/autoAcknowledge). The tagged-union discriminant is now alwayskind, nevertype, and its string values are spelled out (kind: 'increment', not'inc'). Names mirroring external APIs (nats.js, prom-client, amqplib, DOM) and domain acronyms (PubSub,K8s,AMQP,MQTT,SQL) are unchanged. - Runtime floors raised: Node ≥ 24, Bun ≥ 1.3 — Node 20 reached end-of-life in April 2026; Node 24 is the oldest active LTS and the first floor on which the WebSocket client, zstd, WebCrypto and fetch are all native. The Bun floor moves from the two-year-old, never-CI-tested 1.1 claim to 1.3, which CI now actually exercises. Deno stays ≥ 2.0.
WebsocketClientActoralways uses the nativeWebSocket— the dynamicwsfallback for Node < 22 is gone, and with it theheadersclient option (only that path could send custom handshake headers; on native runtimes it was already silently ignored). Migration: pass credentials via query parameter or subprotocol, as browser clients must.wsremains an optional peer for server-side upgrades on the Express backend.typescriptpeerDependency is now^5.6.0 || ^6.0.0 || ^7.0.0— admits TypeScript 7 (the native compiler the repo itself builds with) and raises the floor from the never-verified 5.0 to 5.6.- Dead persistence options removed (#381) —
LiveQueryOptions.batchSize,LiveQueryOptions.clock, the object-storage plugin'sdurableStatePluginId(+ builder method), and the HOCON keyactor-ts.persistence.recovery.mode. All were declared but read by no code. Migration: remove any use — they were no-ops. (Cassandra'sconsistencyis not removed; it is now honoured.) - Example wire protocols discriminate on
kind—examples/cluster/counter-node.tsand the WebSocket frontends follow the project-wide convention.
🔒 Security
- Actor names are validated (#126, #134) — closing a path-forging and a log-injection hole: a name containing a separator produced a path indistinguishable from a different actor, including across the cluster wire where the remote side re-splits the string.
- HOCON config parsing can no longer reach the object prototype (#406).
askreply refs get unpredictable names (#119) — the one-shot reply ref was named from a module-global++askCounter: predictable enough to aim a forged reply at an in-flight ask, shared across everyActorSystemin the process, and prone to wrapping into collisions with names still in flight. Now 12 hex characters fromcrypto.randomUUID.- Numeric gossip and heartbeat fields are checked for plausibility (#113, #115) — a tombstone's
removedAtfailed open atInfinity/NaN, so one forged frame kept a node from ever rejoining. - A TCP socket's nested framing caps are validated (#372) — a non-numeric
maxLineLen/maxFrameLenfrom HOCON did not clamp anything; every comparison againstNaNisfalse, so it removed the DoS cap it exists to enforce. - The cluster-singleton proxy buffer is bounded (#526) —
withBufferSize, default 1000, dropping the newest to dead letters past the cap. Unbounded, a cluster that never elects a host was a memory leak that ends the process. safeStringifyfor error paths (#146) — an unwrappedhandleReplylet a throw escape into the socket decoder loop and abandon every remaining frame in the same batch.- The re-encryption sweep validates keys it gets from
list()(#123) — a malformed key would re-encrypt data under a salt the owning store never reproduces, leaving it permanently undecryptable. - The default 500 response no longer echoes the thrown message.
- Cassandra journal: concurrent appends no longer silently lose events; event tags are validated at the journal boundary; a rejected CAS no longer wedges an object-storage durable-state entry (#117).
ClusterSingletonManagerOptionsis validated (#526).
🐛 Fixed
ask()across nodes gets its reply instead of timing out (#517) — the synthesized reply ref was built as a root path, andActorPathrenders a root without its own name, so the reply travelled back addressed to the bare system root and was dropped. The docs recommendaskfor exactly this case. The ref now lives at/temp/askResp-<id>— which also makes the path unique per call, so two asks in flight at once no longer share one registration.- A role-restricted singleton is hosted by a node that carries the role (#524) — hosting required leader and role, so a role the elected leader did not carry left the singleton hosted nowhere. Host election is now "the first up-member carrying the role", shared by the manager and the proxy so the two cannot drift.
Props.withSupervisorStrategy()actually supervises now (#509) — it was a no-op:ActorCell.onFailurenever read it./useris drained before/systemstarts stopping — so a user actor'spostStopcan still reach the framework actors it depends on.- A
TypedActor'sterminatedsignal is actually delivered (#448); a router prunes a stopped routee (#449) and rejects a pool size that cannot work (#455); a bounded mailbox honours its bound while the actor is suspended (#407). ShardMapChangedis actually published now (#513) — it was declared, exported and consumed, but never emitted.- A stopped singleton can be started again (#523) — the registry was written on start and never emptied, so every later start short-circuited to a proxy that had stopped forwarding. Silent, total, and undebuggable.
- Dead-letter delivery no longer recurses into a stack overflow;
highestSeqno longer rewinds to 0 after a full delete; public-API exports completed. - The whole benchmark suite starts again, and
bun run benchreports failures in its exit code. - Three wall-clock test assertions no longer flake the coverage gate — they wait on conditions now, not on the clock.
📚 Documentation
Cluster.leader()andKeepOldestsay what they actually do (#525) — both were documented as picking the oldest member; both pick the lowest-addressed one. The semantics stay (every node has to name the same one, and address order delivers that without a join sequence on the wire) and the descriptions are corrected, with the rule now stated once under The leader incluster/overview.KeepOldestis the sharper end: a split-brain resolver is chosen for its tiebreak, and the page recommended relying on "a long-running 'stable' node (a coordinator pod) that's almost always the oldest" — which address ordering does not deliver.- Large docs↔source audit pass and persistence snippets reconciled with the real API (#384).
- Docs toolchain moved to Astro 7 + Starlight 0.41 (#474). Every page keeps its 1:1 German mirror — 230 pages in each language.