v0.15.0
The mailboxes and channels release, and the shortest window the project has had: 58 commits and twelve issues in the day since v0.14.0. It is a minor rather than the patch it was planned as, because pre-1.0 puts breaking changes in a minor and this window carries one — the default mailbox. Two threads run through it. Mailboxes — the default is unbounded again, bounding became a deliberate act that names its own loss, and the telemetry that watches what replaced it exists for the first time. Channels — EventStream finally accepts the kind-discriminated message style the rest of the framework argues for, and stops letting one bad subscription take the bus down with it. Underneath, constants got a placement rule, and the rule found a dead export, five duplicated values and a second path-traversal denylist.
⚠️ Breaking changes (pre-1.0)
-
The default mailbox is unbounded again (#1148). Every actor spawned without an explicit
mailboxCapacitynow gets the plain, unboundedMailbox. Since #310 it got aBoundedMailboxwithcapacity = 10_000andoverflow = 'drop-head', which silently discarded the oldest queued message on overflow — no dead letter, no exception, only a counter.#310's trade was worst-case message loss for a guaranteed memory ceiling. The ceiling turned out not to exist: the system-message queue was never bounded (#794), so the framework was paying the loss without collecting the guarantee. And the loss was never confined to the telemetry-shaped workloads
drop-headsuits, because a mailbox cannot tell a stale sample from a control message — the tracker has one entry per victim: a death-watchTerminatedevicted and the watcher blinded (#729), ReliableDelivery sends discarded with theirconfirmnever settling (#732), DistributedDataupdateAsyncpromises stranded unsettled (#1078), and three WebSocket-hub defects where the evicted envelope was a spawn command, aclose()or a disconnect signal (#717, #985, #986). None of those are reachable under the new default.Migration. Nothing to do if you want the unbounded shape — it is the default. To keep a bound, say so at the spawn site:
// before — the bound was implicit, and so was the drop policy system.spawn(Worker, 'worker'); // after — bounding is the deliberate act, and it names its own loss const workerOptions = ActorOptions.create<WorkerMessage>() .withMailboxCapacity(10_000) .withMailboxOverflow('drop-head'); system.spawn(Worker, 'worker', workerOptions);
Unbounded does not mean unobserved.
ActorCellwarns when a mailbox crosses 10 000 queued messages and again at every doubling, and metrics gained theactor_mailbox_sizegauge that the tuning docs had been documenting for a gauge that did not exist.actor_mailbox_dropped_totalstill exists and now only counts drops someone asked for.
🚀 New features
-
EventStreamchannels can bekind-discriminated types, not just classes (#1143).subscribe/unsubscribetook a class constructor and matched withinstanceof, which locked the one API the project offers for loosely coupled fan-out to exactly the message style the project argues against — and sincepublish(event: object)has always accepted a plain object, you could publish something nobody was able to subscribe to. A channel is now named three ways: by a class, exactly as before; by anEventKey, a new export mirroringServiceKey/ShardKey; or by the bare kind string, which costs the type unless you supply the type argument.export type UserLoggedInEvent = { readonly kind: 'user-logged-in'; readonly userId: string }; export const UserLoggedInEvent = EventKey.of<UserLoggedInEvent>('user-logged-in'); eventStream.subscribe(self, UserLoggedInEvent, (event) => event.userId !== 'system'); eventStream.publish({ kind: 'user-logged-in', userId: 'user-42' });
A key and its string are the same channel — subscribing both ways dedups, and either form unsubscribes the other. A class and a kind are two channels even when the class's instances carry that
kind. Prefix and wildcard families ('billing.*') are deliberately not included; they are purely additive later. -
A collision predicate on every random-id helper (#1141).
randomString,randomHex,randomIdandrandomUuidtake an optionalexistscallback and draw again while it answerstrue, so the loop every caller wrote by hand collapses intorandomUuid((id) => state.users.has(id)). The polarity is the design: the callback is thatwhilecondition, which keeps the!offMap- andSet-backed call sites. Bounded at 1 000 draws, then throws anErrornaming the helper and the count.ExistsPredicateis exported from the root barrel. -
Every mailbox reports its drops, not just the one the framework built (#1149).
actor_mailbox_dropped_totalwas fed by anonDropthe cell passed into theBoundedMailboxit constructed, so a mailbox supplied throughwithMailboxwas invisible to it. The cell now registers its observer after choosing the mailbox, on anything implementing the newDropReportingMailboxcontract — a structural probe rather than aninstanceofcheck. Registration is additive: aBoundedMailboxOptions.onDropof your own keeps firing alongside the stock counter. New exports:DropReportingMailboxandMailboxDropReason. -
MailboxandEnvelopeare exported from the package root (#661) — the escape hatch the docs described was previously impossible to import.mailboxOverflow/withMailboxOverflowis a realActorOptionsfield too (defaultdrop-head; setting it without a capacity is rejected rather than silently ignored). -
Three of the framework's own identifier draws go through the
existspredicate (#1146). The framework mints twelve identifiers; only three have a registry in scope and a failure worth preventing.ActorCell's anonymous child names now draw againstthis._children,ORSet.addagainst the element's live tags and its tombstones, andClusterClient.ask's id against the pending map. The other nine are recorded on the issue with the reason rather than left to be re-derived.
🐛 Fixed
-
One faulty
EventStreamsubscription no longer breaks the bus for everyone else (#1010).publishguarded the subscription's predicate but not its channel: theinstanceoftest sat one line above thetryandsubscriber.tellran unguarded below it, so of the three things that can throw per subscription exactly one was covered. A single bad entry raised aTypeErrorinto whoever calledpublish, and because the throw escaped the loop, every subscription registered after it silently stopped receiving anything — in an order decided by subscription order, which no caller controls. Sincepublishruns on every actor start, every actor stop and every dead-letteredtell, that turnedref.tell(…)— an API that does not throw by contract — into one that did.subscribenow rejects a channel that is not a usableinstanceofright-hand side, throwing on the line that wrote the subscription.publishruns the match test, the predicate andsubscriber.tellunder a guard and carries on to the next subscriber. Behaviour change: asubscriber.tellthat throws is now logged and swallowed rather than propagated out ofpublish. -
A dead constant and its live duplicate (#1142).
DEFAULT_SNAPSHOT_CACHE_TTL_MShad zero importers while the consumer its own docblock named declared the same five minutes locally asDEFAULT_TTL_MS. -
reEncryptionSweeprebuilt the ATS1 magic prefix (#1142) instead of importing theATS1_MAGICthatBodyCodecalready exports — a second copy of a format definition. -
The heartbeat interval existed twice (#1142).
defaultFailureDetectorOptionsanddefaultPhiAccrualOptionseach carriedheartbeatIntervalMs: 500, so swapping detectors could silently change how often a node talks to its peers — and only the first was pinned toreference.confby a test. -
Two independently-introduced redraw caps (#1142) and unnamed literals mirroring
reference.conf(#1142) — the dispatcher throughput was a bare16in three places, andShardCoordinatorresolved its rebalance interval and hand-off timeout against?? 2_000and?? 10_000. All now named and verified against the HOCON leaves they mirror.
🔒 Security
- One path-traversal denylist instead of two (#1142).
ActorPathandPersistenceIdValidatoreach declarednew Set(['.', '..'])under a different name. Both guard against the same attack — a persistence id becomes a filesystem or object-storage key where..climbs out of the configured prefix (#133), a path segment reaches actor-selection resolution — and neither imported the other, so extending one would have left the other accepting what it now rejects. Shared asPATH_TRAVERSAL_SEGMENTS, typedReadonlySet. No behaviour change: both sites reject exactly what they did before.
🧹 Internal
- Constants have a placement rule, and follow it (#1142).
src/held ~300 module-levelSCREAMING_SNAKEconstants across 130 files with no documented rule for where any of them belonged. A constant now has exactly four possible homes, checked in order, written down inAGENTS.md. EightConstants.tsmodules hold 42 values; ~20 misplaced options defaults moved next to the option they back. Every public name is unchanged — the barrels re-export from the new location, so no import breaks.