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.

TypeScript
import { topic } from '@sigx/actors';

export const chatMessages = topic<{ from: string; text: string }>('chat-messages');
// per-room: topic('chat-messages', roomId)
TypeScript
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();
        },
    },
});
TypeScript
// 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:

TypeScript
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:

TypeScript
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.