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',
    authorize: requireAuthenticated,
    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.

Settled or accepted#

What a publish waits for is delivery, on host.publish(), publishTopic() and ctx.publish() alike:

TypeScript
await ctx.publish(runCompleted, { runId }, { delivery: 'accepted' });
deliveryResolves whenA handler that throws
'settled' (default)every subscriber's handler turn has finishedis an entry in failures
'accepted'every delivery is queued on its subscriberis dropped and counted in oneWayFailures, like any one-way call

A delivery that cannot be queued at all — the subscriber's host is unreachable, say — is still a failures entry under either mode. The report always carries delivery, because delivered means "handled" under 'settled' and "queued" under 'accepted': an accepted publish cannot report a handler that threw, by construction.

Choose 'accepted' when the publisher's own progress must not depend on its subscribers — the completion path of a workflow, an audit feed, anything fanning into one aggregator. Two things to know before you do:

  • It decouples the publisher, not the subscriber. A singleton subscriber is still a ceiling on how fast events are consumed, and an accepted publish queues on it rather than failing. If that subscriber must shed rather than grow, give it maxQueued.
  • It cannot deadlock. A subscription that dispatches back into the publisher is an ordinary queued turn, because nothing is waiting on it. A settled publish still reports the cycle, as below.

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 — under 'settled' delivery. 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.