Skip to content

Returning commands from a reactor

When a book is added to the catalog, the search index should follow. The indexing command already exists, with its validation and its role check. Instead of calling a service from the reactor and repeating those checks, return the command, and Arc runs it through the same pipeline an HTTP caller would use.

This relies on the SDK’s reactor result hook.

Catalog.ts
import { field } from '@cratis/fundamentals';
import { eventType, type EventContext } from '@cratis/chronicle/events';
import { onceOnly, reactor } from '@cratis/chronicle/reactors';
import { command, key, roles } from '@cratis/arc.core';
import { executeCommandsAsSystem } from '@cratis/arc.chronicle';
@eventType()
export class BookAdded {
@field(String) title: string;
constructor(title = '') { this.title = title; }
}
@eventType()
export class BookIndexed {
@field(String) title: string;
constructor(title = '') { this.title = title; }
}
@command()
@roles('CatalogWriter')
export class IndexBook {
@field(String) @key() id = '';
@field(String) title = '';
constructor(id = '', title = '') { this.id = id; this.title = title; }
handle(): BookIndexed { return new BookIndexed(this.title); }
}
@executeCommandsAsSystem('CatalogWriter')
@onceOnly()
@reactor()
export class CatalogIndexer {
bookAdded(event: BookAdded, context: EventContext): IndexBook {
return new IndexBook(context.eventSourceId, event.title);
}
}

Register all four with builder.add(...) after withChronicle, or with builder.discover(...) in either order. When a BookAdded is appended, Chronicle calls bookAdded, and Arc executes IndexBook: authorization, validation, provide(), handle(), and the append of BookIndexed. The book ID comes from the triggering event’s context, not from the event payload.

A reactor is not an HTTP request, so no caller is signed in. A returned command runs with no principal by default, as in Arc on .NET. A command without authorization rules runs normally; one with @roles, @authorize, or a policy is rejected.

@executeCommandsAsSystem('CatalogWriter') on the reactor class gives the commands it returns an authenticated system principal with exactly the roles you list. Grant only the roles those commands need. The decorator covers returned commands only; a command you execute yourself inside the handler gets nothing from it. It does not prevent replay; @onceOnly() does that for this reactor on SDK 6.9.0 and later.

ValueWithout the decoratorWith the decorator
Arc principalNoneSystem, with the listed roles
TenantThe triggering event’s namespaceSame
Correlation IDThe triggering event’s correlation IDSame
CausationA ReactorEvent entry with the event source ID, event type, sequence number, event store, and namespace, followed by the commandSame

Events the commands append carry Chronicle’s system identity in both cases: the command has no signed-in user, and the principal the decorator supplies is the system identity. Arc executes the commands with an allowed severity of Warning, so warnings do not block and errors do.

Return a nonempty array that contains only commands:

bookRemoved(event: BookRemoved, context: EventContext): (ArchiveBook | RemoveFromIndex)[] {
return [new ArchiveBook(context.eventSourceId), new RemoveFromIndex(context.eventSourceId)];
}

This handler fragment assumes BookRemoved is an event type and ArchiveBook and RemoveFromIndex are commands in your application. Arc runs the commands in order and stops at the first one that fails. Each command is its own execution with its own batch: when the second fails, the first has already committed and stays committed.

Do not mix commands with events or other values in one array. Arc rejects the mixture, and the handler fails, instead of silently dropping an item. Return either only commands or only events.

A returned command that fails, whether rejected by authorization or validation or by throwing, fails the handler with a message naming the command, the event store, and the namespace. Chronicle marks the observer partition as failed rather than acknowledging a partial side effect. When Chronicle delivers the event again, the handler returns the commands again, including any that succeeded the first time.

Reactors accept explicit and kernel-initiated replays by default. Mark the class with @onceOnly() when all its handlers cause non-replayable effects: Chronicle then never replays the reactor at all, neither a full replay nor a partition replay, so a @replay() handler on that class never runs. If only some handlers cause such effects, mark those methods with @onceOnly() instead; Chronicle skips only them during a replay, and @replay() can supply an alternate replay handler. See Chronicle once-only reactors. Marking a reactor once-only does not prevent re-delivery when a failed partition is recovered.

Make the commands safe to repeat even with @onceOnly(). Key them by the triggering event source, check current state in provide() or a read model, or rely on a Chronicle constraint to reject the duplicate.

No transaction spans the triggering event and the commands. The triggering event is already committed when the reactor runs.

With an Arc-owned client ({ connectionString, eventStore }), withChronicle installs the result handler before observation starts. For a client you create yourself, pass reactorCommandResultHandler to the SDK before the client connects:

main.ts (excerpt)
import { ChronicleClient, ChronicleOptions } from '@cratis/chronicle';
import { ArcApplication } from '@cratis/arc.core';
import { ChronicleArtifacts, reactorCommandResultHandler } from '@cratis/arc.chronicle';
import { BookAdded, CatalogIndexer, IndexBook } from './Catalog.js';
const artifacts = new ChronicleArtifacts();
for (const type of [BookAdded, CatalogIndexer, IndexBook]) artifacts.register(type);
let application: ArcApplication | undefined;
const client = new ChronicleClient(ChronicleOptions.fromConnectionString('chronicle://localhost:35000', {
clientArtifactsProvider: artifacts,
discoveryPatterns: [],
reactorResultHandler: reactorCommandResultHandler(() => application!.server, 'Catalog')
}));
const builder = ArcApplication.createBuilder();
builder.withChronicle({ client, eventStore: 'Catalog' });
builder.add(BookAdded, CatalogIndexer, IndexBook);
application = await builder.build();

The SDK calls the handler only while observing, after build() has assigned application. The second argument names the event store Arc appends to; a reactor observing a different store fails instead of running commands in the wrong place. The handler declines results that contain no command, so events returned from other reactors keep the SDK’s own append path.