Skip to content

v0.12.0

Choose a tag to compare

@pathosDev pathosDev released this 01 Aug 16:32
· 1501 commits to main since this release
c66d8f6

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>. The Shard actor owns the entity lifecycle; routing, buffering and passivation policy stay in the region, which is what keeps maxEntities meaning "per node". Handoff is now simply "stop the shard", so HandOffComplete finally means what it says. See Breaking changes for the cost.
  • Shard introspection (#151) — ClusterSharding.shards(typeName) answers cluster-wide with a ShardInfo per placed shard (id, hosting node, region path, live entity count, locality, and a usable ref); shardRefFor(typeName, shardId) hands back one shard's ref and allocates it if it had no home. The coordinator fans GetShardRegionStats out to the registered regions and joins the answers against shardHome; a region that misses the deadline contributes 0 rather than failing the call. New StartEntity / GetShardStats shard 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 consulting extractEntityId, so the message type no longer has to know how it is routed. Synchronous (the shard is hash(entityId) % numShards), and a proxy region is enough to hand one out.
  • SingletonKey and ShardKey — typed, class-declared identities (#523) — declared as a static on the actor itself, tying the typeName and the message type together the way ServiceKey already did for the Receptionist. ShardKey carries the extractEntityId alongside the name; SingletonKey carries an optional role. Identity is the name alone in both cases, and options still override.
  • cluster.singleton (#523) — the facade mirroring cluster.sharding, plus ClusterSingleton.ref(key) for a singleton ref on a node that never hosts it (the counterpart to startProxy), stop(key), managerFor(key) and isStarted(key).

Persistence

  • Relational base layer (#389) — a new SQL backend is a SqlDialect + a SqlPool adapter + 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?() on DurableStateStore, SqlDialect.rowLimit(count), and by-tag projections that accept a full TagFilter.
  • PersistenceExtension.configure({ journal?, snapshotStore? }), plus CassandraJournalOptions.withLightweightTransactions() / .withSerialConsistency().

DevTools

  • An embeddable web UI for a running system (#445) — seven panels on one versioned tap protocol, behind a ./devtools export. Absorbs the separately-tracked live cluster visualizer (#204). --devtools-host makes 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 bounded RecordingTracer with monotonic timings, and RetryOptions.sleep.

Tooling & CI

  • Benchmarks are gated (#506) — a new benchmarks workflow runs typecheck:bench (a benchmarks-only compile) and bench:smoke (every suite, one unwarmed iteration, ~30 s). Nothing looked at benchmarks/ before, so a src/ change that orphaned one was invisible.
  • Package-health CI — publint, arethetypeswrong and knip; plus "sideEffects": false and a memoized ActorPath.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.parent of 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 /user to grouped /system paths (#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 an ActorRef; SingletonHandle is 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.managercluster.singleton.managerFor(key). withTypeName / withProps are unchanged. One behaviour change: stop() on the returned ref is now a warning no-op — it is the proxy, and ActorRef.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 more Cmd/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 always kind, never type, 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.
  • WebsocketClientActor always uses the native WebSocket — the dynamic ws fallback for Node < 22 is gone, and with it the headers client 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. ws remains an optional peer for server-side upgrades on the Express backend.
  • typescript peerDependency 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's durableStatePluginId (+ builder method), and the HOCON key actor-ts.persistence.recovery.mode. All were declared but read by no code. Migration: remove any use — they were no-ops. (Cassandra's consistency is not removed; it is now honoured.)
  • Example wire protocols discriminate on kindexamples/cluster/counter-node.ts and 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).
  • ask reply 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 every ActorSystem in the process, and prone to wrapping into collisions with names still in flight. Now 12 hex characters from crypto.randomUUID.
  • Numeric gossip and heartbeat fields are checked for plausibility (#113, #115) — a tombstone's removedAt failed open at Infinity/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/maxFrameLen from HOCON did not clamp anything; every comparison against NaN is false, 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.
  • safeStringify for error paths (#146) — an unwrapped handleReply let 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).
  • ClusterSingletonManagerOptions is validated (#526).

🐛 Fixed

  • ask() across nodes gets its reply instead of timing out (#517) — the synthesized reply ref was built as a root path, and ActorPath renders a root without its own name, so the reply travelled back addressed to the bare system root and was dropped. The docs recommend ask for 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.onFailure never read it.
  • /user is drained before /system starts stopping — so a user actor's postStop can still reach the framework actors it depends on.
  • A TypedActor's terminated signal 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).
  • ShardMapChanged is 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; highestSeq no longer rewinds to 0 after a full delete; public-API exports completed.
  • The whole benchmark suite starts again, and bun run bench reports 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() and KeepOldest say 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 in cluster/overview. KeepOldest is 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.