Topics
An actor that changes something often needs to tell N interested actors without knowing who they are. Declare the interest on the subscriber and publish from anywhere on the server.
import { topic } from '@sigx/actors';
export const chatMessages = topic<{ from: string; text: string }>('chat-messages');
// per-room: topic('chat-messages', roomId)
export const RoomFeed = defineActor({
type: 'RoomFeed',
use: [sessionGuard],
state: () => ({ recent: [] as { from: string; text: string }[] }),
methods: (ctx) => ({
async recent() { return ctx.snapshot().recent; },
}),
subscriptions: {
// subscriber key = topic key, so RoomFeed/room-1 gets room-1's events
'chat-messages': async (ctx, event) => {
ctx.state.recent.push(event.payload);
await ctx.save();
},
},
});
// from another actor's turn…
const report = await ctx.publish(topic('chat-messages', ctx.key), { from, text });
// …or from a serverFn / script via the running host:
await publishTopic(topic('chat-messages', roomId), { from, text });
Subscriptions are implicit and declarative
The subscriber set is a pure function of the deploy: every registered type whose
subscriptions: names the topic. Nothing registers at runtime, nothing is stored, and there
is no subscribe/unsubscribe call to get out of sync.
A publish activates idle subscribers, exactly the way a reminder delivery does. Each
delivery is an ordinary dispatch of the reserved $sigx:topic method through placement, so a
subscriber owned by another host is reached over the internal transport — HMAC, deadlines,
branded errors, all of it — with no topic-specific wire machinery.
The cost model is S dispatches per publish, where S is subscribing types, not activations.
Delivery is best-effort, at-most-once, and settled
publish() resolves when every subscriber's handler turn has settled, and reports what
happened:
const { subscribers, delivered, failures } = await ctx.publish(chatMessages, msg);
// failures: [{ type, key, message, kind? }]
The publisher never throws for a subscriber. A throwing handler, a dead host or a detected
deadlock all arrive as entries in failures.
Nothing is persisted and nothing is retried — a subscriber that was down missed the event. If that is unacceptable, the event is not a topic; it is state a subscriber should read, or a job.
Backpressure is intrinsic: the publisher awaits the turns, bounded by its call deadline. FIFO holds per publisher→subscriber pair only when the publisher awaits its publishes sequentially; concurrent publishes have no relative order.
The details worth knowing
Key mapping. An entry may be { key: (topicKey) => subscriberKey, handle }. key: () => 'aggregate' makes one singleton receive every key's events. The default is identity — topic
key equals subscriber key.
Cycles are deadlocks, not hangs. ctx.publish carries the publishing turn's call chain,
so a subscription that dispatches back into a non-reentrant publisher fails that delivery
with kind: 'deadlock' in the report. The publisher is awaiting the fan-out, so an undetected
cycle could never complete. reentrant: true delivers inline
instead; reentrant: 'always' delivers as a concurrent turn.
Handlers are turns. They mutate state and ctx.save() like any method. A throwing handler
fails only its own delivery and does not fault the activation. Handlers are not wire-callable
and never appear on the client.
Pages observe topics through a projection. A subscriber actor folds events into state, and the page reads that:
useActorState(RoomFeed, roomId, 'recent', { live: true });
The existing live channel pushes after every handler turn. No new wire, and no way for a browser to subscribe to a topic directly.
Rolling deploys skew the subscriber set. A host publishes to the subscribers its registry declares, so a newly added subscribing type misses publishes from not-yet-rolled hosts until the deploy completes. That is consistent with best-effort delivery, but worth planning a two-phase rollout around if the subscriber is important.
Hot topics pin subscribers active. Every delivery is activity, so a busy topic resets its
subscribers' idleAfterMs clocks.
Reserved names
Topic names may not start with $ or @, and :topic is reserved by the runtime.
Next steps
- Live reads — how a page sees the projection.
- Reentrancy — what changes for cyclic delivery.
- Jobs — when delivery must not be best-effort.
