Skip to content

Test events, reactors and read models without a kernel

EventScenario is a scenario-local event sequence for single and batch accepted appends. No kernel, storage, or observers are started. The scenario uses the production constraint compiler, then evaluates only the fixture-backed subset below. For selected event types with default constraint discovery, it compiles globally discovered fluent constraints and the union of globally discovered and selected event constructors (deduplicated by constructor), then keeps definitions referencing a selected event type (as constrained or removal event). Selected decorators still apply when the discovery registry was cleared. Different constructors with the same event type ID reject with UnsupportedEventSequenceOperation when either carries constraint decorators; otherwise the selected constructor is used. Incomplete and unsupported definitions also reject rather than being approximated. Supply an isolated event catalog and explicitly disable constraints only when the behavior under test does not depend on them:

import { field } from '@cratis/fundamentals';
import { eventType } from '@cratis/chronicle';
import { EventScenario } from '@cratis/chronicle/testing';
class MessageRecorded {
@field(String) label: string;
constructor(label: string) { this.label = label; }
}
eventType('MessageRecorded')(MessageRecorded);
const scenario = new EventScenario({
artifacts: { eventTypes: [MessageRecorded] },
constraints: 'disabled'
});
await scenario.given.forEventSource('message-1').events(new MessageRecorded('seed'));
const result = await scenario.when.forEventSource('message-2').event(new MessageRecorded('act'));
// result.isSuccess === true; scenario.results contains the act-phase result only.
const history = scenario.appendedEvents; // Serialized, independent snapshots of setup and act.
const events = await scenario.eventSequence.getFromSequenceNumber(result.sequenceNumber);

For an unscoped unique string property, include the decorated event type in the catalog and leave constraints enabled. Fixture-backed constrained values include digits, punctuation, empty or padded strings, and é; a schema-backed boolean is also supported. This is narrower than the kernel’s general conversion rules:

import { field } from '@cratis/fundamentals';
import { eventType, unique } from '@cratis/chronicle';
import { EventScenario } from '@cratis/chronicle/testing';
class SubscriberRegistered {
@field(String) @unique('SubscriberEmail', 'Already used: {PropertyValue}') email: string;
constructor(email: string) { this.email = email; }
}
eventType('SubscriberRegistered')(SubscriberRegistered);
const uniqueScenario = new EventScenario({ artifacts: { eventTypes: [SubscriberRegistered] } });
await uniqueScenario.given.forEventSource('first').events(new SubscriberRegistered('alice'));
const rejected = await uniqueScenario.when.forEventSource('second').event(new SubscriberRegistered('alice'));
// rejected.isSuccess === false; rejected.constraintViolations[0].message === 'Already used: alice'.

You can use your application’s concept-typed fields without replacing them with primitives in tests. Declare @field(AuthorName) on the event property and static readonly valueType = String on an AuthorName extends ConceptAs<string> concept. Use Boolean, Number, Guid or Date for the corresponding concept value type. Both legacy and standard decorators use the connected client’s schema generation and JsonSerializer: history, reactor deliveries and observing reducers receive serialized primitives, not concept wrappers. Non-constrained numeric, Guid, date/date-time and object fields are accepted, including typed nested objects. They do not need unique-key comparison semantics. Projection mappings still have their own limits: supported scalars project normally; object targets remain rejected.

Unique keys have a narrower boundary. String and boolean concepts retain their existing limits. Guid and numeric concepts support the isolated, single-property cases below. Date/date-time and object keys still throw UnsupportedEventSequenceOperation naming artifacts.constraints; disabling constraints explicitly accepts those payloads but does not test their uniqueness.

scenario.eventLog is the same sequence when the sequence ID is event-log. scenario.then is a non-callable assertion view with results and appendedEvents; use ordinary assertions on it. Direct calls to scenario.eventSequence.append and appendMany also enter results. given.forEventSource(id).events(...events) performs sequential single appends (including zero events), matching .NET setup; setup results are excluded. The scenario checks every setup event first: if any is rejected, it throws and commits none of that call’s events. In .NET, setup ignores each append result, so a rejected event is silently skipped and other events before and after it are still appended. when.forEventSource(id).event(event) is the single-event act. In TypeScript, plural when...events(...) performs one atomic batch, including a one-event call, and returns one result per event. This deliberately differs from .NET’s When.Events, which appends sequentially and returns one result. Empty batches reject. No append runs projections automatically; ReadModelScenario remains independent.

const results = await scenario.when.forEventSource('message-2').events(
new MessageRecorded('first'), new MessageRecorded('second'));
const mixed = await scenario.appendMany([
{ eventSourceId: 'message-1', event: new MessageRecorded('other') },
{ eventSourceId: 'message-2', event: new MessageRecorded('more'), subject: 'message-2' }
]);
// Both calls append atomically, in input order. mixed and results each contain per-event AppendResult values.

For the mixed-source overload, per-entry source/stream routing, subject, occurrence and tags override or combine with shared AppendOptions as in the production client: the scenario calls production prepareBatchAppend. Nonempty route and subject strings contain only ASCII letters, digits, _ and -. Empty route strings resolve to the default dimension, including an empty per-entry value overriding a shared route; empty subjects remain rejected. Tags that JavaScript trim() reduces to an empty string are removed by production mergeTags before validation. Every other tag must contain only ASCII letters, digits, _ and -; otherwise the append is rejected. Custom GUIDs must be valid. Shared or per-entry occurrence dates must be within UTC years 1–9999. An omitted route resolves to Default/All/Default; a separate batch oracle case sends omitted and empty route fields directly to the pinned kernel to verify that resolution. Read filters support source, type and route combinations within that domain; as the kernel fixture demonstrates, Default source type, All stream type and Default stream ID are non-narrowing filters (not exact-route filters). appendOperations is the same hot client-side notification stream as production: subscribe before appending; single appends, including each setup event from given.events, publish a one-entry array; appendMany and plural when.events publish one array with an entry per input event. Notifications carry the client-side input event, not a persisted-event snapshot.

OperationBasic EventScenario boundary
Single and atomic batch append (both overloads), sequential multi-event setup, plural batch action, global zero-based sequence allocation, hasEventsFor, next/tail (including type and route filters), source/type/route reads, inclusive reads from sequence (including type filters), append notificationsSupported for registered generation-1 events with unclassified string, boolean, numeric, Guid, date/date-time and object fields (including scalar concepts) and matching serialized JSON content. History and sequence reads contain client-serialized JSON, not kernel-normalized content. Guid casing and date-time spelling can differ from kernel reads (for example Z versus +00:00, or dropped .000); compare Guids case-insensitively and dates as instants, not strings. Objects retain the production serializer’s nested content; typed nested schemas are checked, and nested compliance/security metadata still rejects. Formatted strings must be dashed Guids, ISO dates (YYYY-MM-DD), or UTC ISO date-times (seconds with no fraction or three-digit milliseconds and Z), with valid calendar dates in years 1–9999. Numeric fields must be finite; integer formats enforce their signed/unsigned width and require safe integers (also for int64/uint64). Unknown numeric formats reject. Top-level array fields remain unsupported. Empty schemas are supported only for unique-property removal-only events in the pinned lifecycle fixture. The existing plain-string value domain remains strings containing only printable ASCII (U+0020–U+007E) except " and \, plus é (U+00E9); other strings are rejected with UnsupportedEventSequenceOperation. Results include successful appends; accepted history includes setup.
Event metadataDefault source/stream routes (Default/All/Default), subject equal to source ID, store test-event-store, namespace default, correlation, occurrence time (with deterministic scenario clock/ID hooks, UTC years 1–9999 at JavaScript Date millisecond precision), system identity, and a deterministic SHA-256/base64 content hash. Kernel hash parity is established only for the original string/boolean content domain and the new constraint fixture’s accepted values; other numeric, Guid, date and object payloads have a scenario-local hash, not a persisted-kernel hash. Do not use these hashes to test kernel content canonicalization. Fixture-backed batch routing, tags, subject and occurrence metadata are supported with per-entry precedence; single appends accept only the routing options sourceType, streamType and streamId. Other single-append custom metadata, concurrency and unproven serialization are rejected. The TypeScript client’s append causation preparation is shared with production; kernel fixtures verify Root → TypeScriptClient.Append for single appends and Root → TypeScriptClient.AppendMany (with an event-count property) for batches. The .NET client’s default one-entry Unknown chain is different. Stored reads expose the kernel’s initial observation state (1).
Unscoped constraintsProperty-level @unique and fluent unique(...).on(Event, event => event.key) for one case-sensitive, schema-backed string or boolean key per event type, plus the isolated Guid and numeric key shapes below, or a fluent composite of two or three string properties, for example unique(key => key.on(NameRegistered, event => event.first, event => event.last)) (see the composite table below). Fluent .ignoreCasing() is supported for string keys in the ASCII key domain (see the case-insensitive table below). The key-domain table below applies to constrained values; it does not widen event content. Class-level @unique or fluent uniqueFor supports one covered event type per constraint, or one of the two pinned removal-cycle shapes in the table below. Conflicts with accepted history and earlier entries in the same batch reproduce the fixture’s violation count and order across events, including default property messages for in-batch conflicts and same-source reclaims, and violation details. A successful same-source replacement frees the old value, and a registered removal event frees that source’s value; repeated or wrong-source removals do not free another owner’s claim. A covered-and-removal event validates before releasing on commit. During batch validation, neither replacements nor removals release earlier or durable property claims: a batch [remove A, B claims A's key] still fails atomically, whereas separate successful appends allow the reclaim. Multiple constraints covering the same event type, a remover covered by another definition, or a remover shared by several definitions are rejected: fixtures do not establish their interaction. Configured {PropertyName} and {PropertyValue} messages use production substitution. Rejected batches return one failed result per input (sequence number normalized to 0n), commit nothing, and do not consume sequence numbers; a failed result’s waitForCompletion() resolves with the SDK’s successful no-work value. Setup violations throw and roll back that given call.
Empty batches, unresolved removal names, scoped shapes outside the scope table below, composite or case-insensitive keys outside the tables below, overlapping validating definitions, unique-event-type shapes outside the cycle table below, migrations, tombstones, alternate generations, protected fields, array fields, unproven constrained key types, completion/redaction, transactions, observer-tail and unproven metadata/read filtersUnsupported: UnsupportedEventSequenceOperation names the operation and artifact and says “Use a kernel-backed test.” Unproven key conversions and event content reject before mutation. Explicit constraints: 'disabled' is for scenarios that deliberately do not test constraints; it is never a silent fallback. Accepted single appends cannot wait for observer completion because no observers run.
Unique-property key domainIn-process support
Case-sensitive stringsEmpty, one-space and padded strings; ASCII letters/digits, space, ., @, _, -, :, |, {, }, $ (including email-like strings); also U+00E9 (é). Other punctuation may be valid event content but is not proven as a constrained key. No trimming, Unicode normalization or casing conversion.
Schema-backed booleanstrue hashes as True, false as False. A string "True" shares a key with boolean true; "true" is distinct. Violation details contain True or False, not JavaScript lowercase spelling.
LifecycleEach source owns its most recently committed key. A replacement frees its old key; a removal event releases that source’s claim even without a value. Whole-batch validation still sees pre-batch claims and earlier batch claims until commit, even when removals/replacements occur before another source’s reclaim.
Guid keysDashed GUIDs (guid schema format only), including ConceptAs<Guid>. The unproven uuid constraint schema alias is rejected. Casing is normalized before comparison and in violation details. ABCDEF01-ABCD-ABCD-ABCD-ABCDEF012345 conflicts with its lowercase spelling.
Numeric keysUnformatted Number or number/double schemas, including ConceptAs<number>, with safe-integer values from -9007199254740991 through 9007199254740991. The kernel compares 1 and 1.0 as the same key. Production JavaScript serialization turns -0 into 0 before the scenario compares it.
Guid/numeric definition shapeOne unscoped, case-sensitive, single-property definition covering one event type, without removers or other definitions. Same-source replacement and atomic batch conflicts are supported. Evidence: constraints-field-types.json, checked against the packaged kernel with literal JSON requests and raw violations.
Still rejectedFractional and unsafe-integer numeric keys, other numeric schema formats, date/date-time, null/missing, array, object or mismatched schema/value keys; composite, scoped, ignoreCasing, shared-event and removal definitions for Guid/numeric keys; unproven punctuation, Unicode, escapes and control characters; scoped boolean keys. These restrictions apply to constraints, not ordinary numeric/Guid/date/object payloads. Use a kernel-backed test.
Composite unique-property keyIn-process support (constraints-composite.json)
ShapeTwo or three distinct flat string properties per event type, in fluent on(Event, ...properties) order. A shared definition may mix a composite on one event type with a composite in a different property order, or a single string key, on another.
KeyThe kernel joins each property’s string with a literal - in declared order, not field order, then hashes the result. ['Ada', 'Lovelace'] and ['Lovelace', 'Ada'] are different keys. Empty components still participate, so ['', ''] claims -.
Delimiter collisionsThe joined key is not a tuple. ['a-b', 'c'], ['a', 'b-c'] and a single key a-b-c in the same definition collide, as do ['', 'q-r'] and ['-q', 'r']. Choose component values that cannot contain - if this matters.
ViolationsOne violation per declared property, in declared order, each with that property’s own PropertyName and PropertyValue and the same sequence number; a configured message is resolved separately for each.
Lifecycle and batchesSame as single keys: same-source reclaim, replacement and removal of the whole composite claim, and whole-batch validation across event types. Separate definitions never collide with each other.
Still rejectedBoolean or other non-string components, repeated property paths (the kernel builds a dictionary keyed by path), more than three properties, nested or indexed paths, scoped composite definitions, and any component value outside the case-sensitive string domain above. Use a kernel-backed test.
Case-insensitive unique-property keyIn-process support (constraints-ignore-casing.json)
FoldingFluent unique(key => key.on(HandleRegistered, event => event.handle).ignoreCasing()). The kernel lowercases the joined key with .NET ToLowerInvariant before hashing; for the ASCII key domain that maps only A–Z to a–z. Alice, ALICE and alice collide; digits, spaces and the punctuation listed above are unchanged and still significant (Alice and Alice differ).
Details and messagesViolation details keep the attempted value’s original casing (ALICE, not alice), in configured messages too.
OwnershipA source may reclaim its own key with different casing; the violation then reports the latest claim’s sequence. Replacement and removal release the folded key. Batches use the same folded key, so [F: Zed, G: zED] fails atomically.
CompositesFolding applies across the whole joined key, so ['Ab-C', 'd'] and ['ab', 'c-D'] collide; ['AB', 'C-E'] does not.
Still rejectedAny non-ASCII character (including é, which case-sensitive keys accept), because the pinned fixtures do not establish the kernel’s Unicode lowercasing and host JavaScript case tables are not a substitute; boolean keys or scoped definitions under ignoreCasing; @unique merged with a case-insensitive fluent definition (a compiler conflict). Use a kernel-backed test.

constraints-field-types.json also records oracleGuard cases, not extra supported domains. Fractional/exponential and unsafe-integer requests capture the kernel’s formatting without promising general JavaScript/.NET floating-point equivalence. The date-time case uses the installed string/date-time schema (DateTime, matching TypeScript Date), not DateTimeOffset. Equivalent instants with different offsets collide, and fractional seconds disappear from keys and violation details. Date keys therefore remain rejected rather than using JavaScript date equality. Each case records and asserts the effective installed property schema; numeric evidence covers both the exact unformatted TypeScript number schema and number/double.

Rejecting non-ASCII keys under ignoreCasing is a deliberate, permanent default, not a pending gap: the scenario maintains no Unicode case mapping of its own, so it cannot drift from the kernel’s. Test non-ASCII case-insensitive keys against a kernel.

Unique event type cycleIn-process support
Definition shapesExactly the two pinned shapes, each as the only definition in the scenario: two covered types with two separate removers (constraints-event-type-siblings.json), or three covered types with three removers where one covered type is also a remover (constraints-event-type-cycles.json). Same-named class-level @unique or fluent uniqueFor declarations compile to one definition; removers use @removeConstraint('Name') and must have at least one field.
ClaimsPer event source, any covered event blocks every covered type until a remover for that source commits. Another source’s remover releases nothing. Either remover releases; a remover without an open cycle is accepted. Violations use the definition’s message.
Covered removerValidated against the open cycle first: blocked while the cycle is open, otherwise it claims and immediately releases, so a later covered event in the same batch succeeds. A blocked covered remover releases nothing.
Atomic batchesA remover earlier in the batch releases the cycle for later events in the batch; a second covered event after an in-batch claim is blocked. A rejected batch commits nothing, including its releases. The raw kernel violation reports the durable holder’s sequence number (the first covered event after the source’s latest committed remover), or 18446744073709551615 when only an earlier event in the batch holds the cycle.
Still rejectedOne covered type with removers, several covered types without removers, any other count or overlap of covered and removal types, a cycle definition next to any other definition, fieldless event-type removers; scoped covered-and-removal cycles. Use a kernel-backed test.

The committed Source/testing/fixtures/*.json snapshots run through the real in-process kernel via the pinned Cratis.Chronicle.Testing 19.26.2 oracle. yarn oracle:check verifies them alongside projection fixtures. The fixture tests also compare the TypeScript client’s serialized content, context fields, hash, result shape and essential reads. The boundary fixture covers an empty string, the supported printable ASCII range, a mixed-case property name and exclusion of a different event type on the same source. The source-tail fixture distinguishes the last event for A from the global tail. batches.json checks kernel-stored resolved per-entry metadata, the tag merge (including duplicate removal), correlation ID, ordering, hashes, batch causation, read filters and empty-batch rejection. Its .NET client-path oracle resolves per-entry versus shared route, subject and occurrence options before sending each event and supplies explicit route defaults; it does not independently prove those precedence rules. Those rules come from production prepareBatchAppend shared with the scenario. The separate batch-omitted-routes.json fixture bypasses the .NET convenience type and sends genuinely omitted and empty routes through the pinned kernel’s batch service, verifying Default/All/Default resolution without claiming client-path notifications. Empty subjects remain outside the supported domain; the scoped wire fixtures also prove empty-route omission through the TypeScript encoder. builders.json proves .NET’s sequential setup/action semantics; batch-rollback.json proves rejected unique-constraint batches are atomic and leave no sequence gap. constraints.json proves string key ownership across event types and sources, ordinal casing and significant spaces, decorated and fluent unique-event-type rejection, raw wire violation fields, default property messages for in-batch conflicts and post-reclaim cross-source conflicts, successful same-source same-key batches with both sequences committed and a subsequent cross-source conflict reporting the last sequence, multiple failures on different events in one batch, and atomic rollback. It does not prove the violation order when one event violates multiple definitions; scenarios reject that overlap. These cases exercise the packaged kernel; the TypeScript spec compares every result and committed-history snapshot to the fixture. constraints-isolation.json selects only its declared text definition, and constraints-key-domain.json installs a shared string/boolean definition with explicit schemas. Both capture raw kernel violations, mapped results, routes, content, hashes and accepted history after each single or batch operation; failed appends assert unchanged history and sequence. The oracle checks effective installed definitions against fixture order, property names, scope, removal and casing and pins both the sequence and constraint contract descriptors. constraints-property-lifecycle.json and constraints-property-covered-removal.json separately capture replacement, removal, and batch non-release from pinned kernel storage: raw and mapped violations, every stored hash and history snapshot, and atomic rollback. The removal fixture checks three alternative removers, including a fieldless event; the covered-removal fixture shows validation before release. constraints-event-type-siblings.json and constraints-event-type-cycles.json each install one unique-event-type definition and capture cycle claims, releases by each remover, other-source removers, repeated removers, a covered remover, in-batch release and reclaim, blocked batches and their rollback, with raw and mapped violations and every stored hash. Composite keys are not inferred from these fixtures; constraints-composite.json separately installs a two-property definition shared across three event types (including a reversed declared order and a single key) and a three-property definition, and captures declared-order joining, delimiter collisions, empty components, per-property violations and messages, reclaim, replacement, removal and batch collisions. constraints-ignore-casing.json installs a case-insensitive single key with a remover and a case-insensitive composite, and captures ASCII case pairs, punctuation/digit/space significance, case-only same-source reclaim, original-cased details, replacement, removal, batch collisions and folding across component boundaries. Blank or whitespace-padded source filters are rejected until their normalization is fixture-backed. They do not establish production storage, concurrency, compliance or scheduler fidelity; use a kernel-backed test for those behaviors.

Named tags are not fixture-backed. A nonempty namedTags option or batch entry rejects with UnsupportedEventSequenceOperation (append.namedTags or appendMany.namedTags); an empty list is accepted, and stored contexts carry an empty namedTags list as a kernel read would. ReadModelScenario setup rejects them the same way. Use a kernel-backed test for named tags.

Use a fluent constraint to keep unique values independent per stream ID. @unique itself is unscoped; do not combine it with a same-named scoped fluent definition.

import { field } from '@cratis/fundamentals';
import { constraint, eventType, IConstraintBuilder } from '@cratis/chronicle';
import { EventScenario } from '@cratis/chronicle/testing';
@eventType('ScopedEmailRegistered')
class ScopedEmailRegistered {
@field(String) email: string;
constructor(email: string) { this.email = email; }
}
@constraint('EmailPerStream')
class EmailPerStream {
define(builder: IConstraintBuilder) {
builder.perEventStreamId().unique(key => key.on(ScopedEmailRegistered, event => event.email));
}
}
const scoped = new EventScenario({
artifacts: { eventTypes: [ScopedEmailRegistered], constraints: [EmailPerStream] }
});
await scoped.given.forEventSource('first', { streamId: 'west' }).events(new ScopedEmailRegistered('alice'));
const independent = await scoped.when.forEventSource('second', { streamId: 'east' }).event(new ScopedEmailRegistered('alice'));
const duplicate = await scoped.when.forEventSource('third', { streamId: 'west' }).event(new ScopedEmailRegistered('alice'));
// independent.isSuccess === true; duplicate.isSuccess === false.

For a combined scope, replace builder.perEventStreamId() with builder.perEventSourceType().perEventStreamType().perEventStreamId() and pass, for example, { sourceType: 'Customer', streamType: 'Registration', streamId: 'west' } to forEventSource(id, options) or append. Every selected dimension must match to share a constraint scope. Event source ID is already the property-claim owner or event-cycle identity; source type is a separate, optional scope dimension.

Scoped constraintIn-process support (constraints-scopes.json)
DimensionsAll seven nonempty combinations of perEventSourceType(), perEventStreamType() and perEventStreamId(), using exact, case-sensitive comparisons. Changing an unselected dimension does not open a new scope.
DefinitionsOne definition per scenario: case-sensitive single-string property keys (including shared event types and ordinary removal events), one unique event type without removers, or two covered event types with two separate removers. Use fluent uniqueFor(Event, message, sharedName) on separately identified @constraint classes to merge scoped covered types; @removeConstraint(sharedName) names their removers.
Ownership and cyclesThe same source can hold a key in each scope. Replacement and removal affect only that owner’s matching scope. Property claims remain held throughout batch validation; event-type cycles can release and reopen within a batch. A failed batch releases nothing.
Routes and defaultsappend(source, event, options), both appendMany overloads, and EventScenario/ReactorScenario given/when builders support routing. Omitted or empty dimensions resolve to Default / All / Default. These are exact scope values, unlike the non-narrowing defaults in read filters. Per-entry batch routes override shared options, even when the per-entry string is empty.
Still rejectedScoped composites, ignoreCasing, boolean keys, fieldless removers, covered-and-removal events, and scoped definitions alongside other definitions. Route identifiers containing spaces, delimiters, non-ASCII or other unproven characters remain rejected. Custom stores/sequences, concurrency and arbitrary single-append metadata still need a kernel-backed test.

The oracle asserts effective installed scopes in the packaged kernel and executes both the .NET client path and decoded TypeScript protobuf requests. It compares raw violations, mapped results, full history, routes, hashes and rollback. Its delimiter-alias guards show why property scope strings and event-cycle tuples are not interchangeable; the scenario rejects those identifiers rather than promising storage-provider alias behavior.

Select a route with given.forEventSource(id, options?: AppendOptions) or when.forEventSource(id, options?: AppendOptions). The options apply to every event in that builder call. Setup uses the same validation and scoped constraints as append; EventScenario.when.event uses single append, while when.events uses atomic appendMany. Unsupported options throw exactly as the corresponding direct append does: setup and single actions accept only sourceType, streamType and streamId; batch actions retain the batch metadata boundary described above. Omitting options preserves default routing.

ReactorScenario passes the recorded route to handlers in EventContext, and retains it in handled/skipped contexts and each side effect’s triggeringContext. Reactor handlers can also append non-subscribed events through services.eventStore.eventLog with the same supported options; those service appends are recorded but not delivered.

ReadModelScenario.given.forEventSource(id, options?: AppendOptions) shares single-append route validation and accepts omitted, explicit or empty-string defaults. Nondefault routes still throw UnsupportedProjectionOperation before collecting events: routed projection/reducer evaluation is not fixture-backed. The overload does not enable projection routing.

ReactorScenario: live event deliveries and recorded effects

Section titled “ReactorScenario: live event deliveries and recorded effects”

ReactorScenario shares the production reactor’s handler discovery, per-event invocation boundary and returned-event normalization. Its input is the fixture-bounded EventScenario: each given or when call appends registered events and immediately delivers their serialized history in source-partition order. Reactor inputs are client-serialized JSON, not kernel-normalized content, so Guid casing and date-time spelling can differ from production (for example Z versus +00:00, or dropped .000). Compare Guids case-insensitively and dates as instants, not strings. given.events uses sequential single appends; when.events uses an atomic batch (including one event). Both await completion before returning. A returned event is recorded, not appended or recursively delivered. In production, returning an event type that the same reactor subscribes to delivers it back to the reactor; the scenario does not simulate that feedback loop.

import { field } from '@cratis/fundamentals';
import { eventType } from '@cratis/chronicle';
import { reactor } from '@cratis/chronicle/reactors';
import { ReactorScenario } from '@cratis/chronicle/testing';
class WelcomeRequested {
@field(String) name: string;
constructor(name: string) { this.name = name; }
}
eventType('WelcomeRequested')(WelcomeRequested);
class WelcomeSent {
@field(String) name: string;
constructor(name: string) { this.name = name; }
}
eventType('WelcomeSent')(WelcomeSent);
class WelcomeReactor {
welcomeRequested(event: WelcomeRequested) { return new WelcomeSent(event.name); }
}
reactor('WelcomeReactor')(WelcomeReactor);
const reactorScenario = new ReactorScenario(WelcomeReactor, {
artifacts: { eventTypes: [WelcomeRequested, WelcomeSent] },
constraints: 'disabled'
});
await reactorScenario.given.forEventSource('customer-1').events(new WelcomeRequested('alice'));
await reactorScenario.when.forEventSource('customer-2').events(new WelcomeRequested('bob'));
reactorScenario.shouldHaveProduced(WelcomeSent, sent => sent.name === 'bob');
// reactorScenario.produced contains two events; returned events are not delivered again.

results contains a delivery outcome per nonempty call: the handled and skipped event contexts, completion status, and any error. sideEffects retains each returned event, its target routing, triggering context, handler and delivery index; produced flattens the event values. then is a non-callable view of all three. A failed delivery records its partial observations and rejects the awaited call. It stops on the failing event; the undelivered tail is not included in the outcome. After a delivery failure the whole scenario rejects further deliveries (including to other partitions) before appending, since failed-partition retries and checkpoints are not simulated. Use a kernel-backed test to exercise recovery.

With artifactActivator, the scenario activates once per source-partition delivery with the first invocable event context, calls run for each handled event and its returned effects with delivery: Events, that event’s context and its method name, calls complete() once even after processing failure, then disposes. Both processing and completion failures survive in ArtifactCompletionFailed; disposal-only failures are logged. Constructor dependencies belong in this production activator, not a scenario-specific DI container. Without an activator the instance is reused across deliveries, matching the TypeScript runtime (not .NET’s fresh-instance default). The third handler argument is production ReactorServices; the default scenario store exposes its event log for explicit appends, but unsupported read-model and other store methods reject. Explicit appends are recorded in history but not delivered to any observer. An append (including a mixed batch) of an event type this reactor subscribes to rejects before committing, rather than silently skipping its follow-up delivery. An explicit store test double is outside that guard; its behavior is the test’s responsibility. Supply servicesEventStore as an explicit test double for other dependencies. A resultHandler follows production semantics: returning true claims the whole result, false falls back to recording, and throwing fails delivery.

The existing committed EventScenario kernel fixtures prove zero-based sequence allocation, serialized context fields and initial observation state for their own event histories: builders.json covers sequential setup appends and batches.json covers atomic batch actions. Reactor specs exercise contexts from the same EventScenario boundary, not a field-by-field comparison with the fixtures. Grouping one multi-event given.events call into one delivery is a scenario convention, not a claim about kernel queue batching. Runtime reactor activation and failure specs exercise the shared dispatcher. This does not simulate observer scheduling, retries, checkpoints or distributed ordering. Constraint-bearing reactors use the same selected EventScenario catalog: a rejected append does not deliver to a handler. A rejected when.events action throws ReactorScenario action append failed with the failed append results (constraint names, resolved messages and errors); a rejected given.events setup throws EventScenario given setup failed with its failed result. @filterEventsByTag reactors reject at construction; replay() and redeliver() reject with UnsupportedReactorOperation until a separate kernel-backed increment. An unsupported service or event-log call inside a handler (including waitForCompletion() on an append result) fails the delivery at that event with its UnsupportedReactorOperation or UnsupportedEventSequenceOperation, even if the handler catches the rejection: nothing is recorded for that event and later events are not delivered. A call is attributed to the delivery whose handler started it, and only while that delivery is running; leftover work from an earlier delivery never fails a later one. commandTypes and unknown return shapes likewise reject (commands are not classified in this increment). Returned effects are not validated as kernel appends, except that namedTags on a returned EventForEventSourceId are validated as production dispatch does: an invalid tag fails the delivery with InvalidNamedTag, and valid tags are kept on the recorded target. A returned effect or batch that combines non-empty named tags with any registered eventSource entry fails with NamedTagsWithRegisteredEventSourceNotSupported before any effect is recorded, even when the tags and registered source belong to different entries. Reactor effects are always appended as a batch, so this also applies to a single returned effect. Empty named-tag arrays remain accepted. This matches the production guard for the kernel’s registered-source routing limitation. Use a kernel-backed test for storage, replay, read-model materialization or command execution.

Import ReadModelScenario from @cratis/chronicle/testing. Associate a reducer with its read model using the third argument of reducer(). Seed events for an event source, then await the instance:

import { eventType, reducer } from '@cratis/chronicle';
import { ReadModelScenario } from '@cratis/chronicle/testing';
class BookBorrowed {
constructor(readonly title: string) {}
}
eventType('book-borrowed')(BookBorrowed);
class BookStatus {
title = '';
}
class BookStatusReducer {
bookBorrowed(event: BookBorrowed): BookStatus {
return { title: event.title };
}
}
reducer('book-status-reducer', undefined, BookStatus)(BookStatusReducer);
const scenario = new ReadModelScenario(BookStatus);
scenario.given.forEventSource('book-42').events(new BookBorrowed('Dune'));
const status = await scenario.instanceForEventSourceId('book-42'); // { title: 'Dune' }

scenario.instance returns the sole materialized model (and rejects ambiguity if more than one exists). For multiple sources use await scenario.instanceForEventSourceId(id); both return null when no model exists. await scenario.wasDeletedForEventSourceId(id) distinguishes a removed model from one never created. Events replay in seed order, with zero-based sequence numbers assigned globally across sources. Adding seeds after a read replays the complete history. When a reducer and a projection both apply, the reducer takes precedence: its handlers receive previous state and EventContext, async handlers are awaited, undefined deletes the model, and null preserves a null state for the next handler.

Without a reducer, ReadModelScenario compiles the same model-bound or declarative projection contract used by registration and validates the whole definition before replay. Supply all participating event types in the optional artifact catalog if you isolate discovery. For a declarative projection without an explicit read-model type, also supply the production read-model catalog (readModels) so association is inferred against the same candidates; without it the scenario does not link that projection. An unsupported mapping fails even if you never seed its event. A read model cannot have multiple applicable projections.

CapabilityIn-process scenario
Root fromEvent / .from() keyed by $eventSourceId, multiple event types and source IDsSupported for read models with a lowercase id schema: string, canonical GUID, or canonical number/double identifiers. Missing, Id-only, unformatted number, and integer identifier schemas require a kernel-backed test.
setFrom, schema-based case-insensitive AutoMap, noAutoMapSupported for scalar targets only (string, nullable string, boolean, GUID, date-time, number/double, int32/uint32), with matching source type/format, GUID to plain string, int32/uint32 to number/double (including unformatted TypeScript Number), and numeric text to int32/uint32/number/double (including unformatted Number). Decimal-spelled text is rejected for integer destinations. Date-time mappings accept validated four-digit-year UTC ISO values (no fraction or three-digit milliseconds) in the .NET DateTime range (years 1–9999); other formats and out-of-range values are rejected. Explicit mappings from event properties that differ only by case preserve each non-null exact-case value; a null exact-case value falls back to the first case-insensitive property in JSON order, which can itself be null. Ambiguous inferred AutoMap sources are rejected. The event-property paths true, True, false, and False are rejected because the kernel resolves them as literals. Dotted event-content source paths are not fixture-backed and are rejected. Event string/GUID fields must contain JSON strings. Other cross-type conversions and object/array target mappings require a kernel-backed test. Mapping id (including AutoMap), or a target that collides case-insensitively with another schema property, is rejected.
$eventSourceId, proven $eventContext(...) scalar roots/paths, $value(...), $null / scalar clearing$eventSourceId requires a plain string or GUID target; other target types require a kernel-backed test. Fixture-backed context paths have string targets for EventSourceId, EventStore, Namespace, EventSourceType, EventStreamType, EventStreamId, Subject (and .Value), Hash, CorrelationId (and .Value), CausedBy.Subject/Name/UserName, EventType.Id.Value, and SequenceNumber (and .Value); int32 targets for EventType.Generation.Value and Occurred.Year/Month/Day. Subject defaults to the event source ID; omitted Hash is empty and CausedBy uses the kernel’s [Not Set] identity. Raw Occurred, whole EventType/CausedBy, Causation, Tags, ObservationState, and derived functions require a kernel-backed test. Literal values are supported for the fixture-backed scalar matrix (number/double, int32, uint32, boolean, string, nullable string, and GUID); $null clears populated scalar members, including non-nullable number, boolean, GUID, and string. Date-time literals require a kernel-backed test.
Root arithmetic: fluent .add(...).with(...), .subtract(...).with(...), .count(...), .increment(...), .decrement(...); model-bound @addFrom, @subtractFrom, @count, @increment, @decrementSupported in both decorator modes with unformatted number accumulators (@field(Number)), keyed by $eventSourceId. The evaluator also accepts explicitly registered number/double and integer/int32 schemas; decorators do not generate these formats, so int32 wraparound applies only to hand-built or foreign schemas. Add/subtract operands must be direct event properties with number/double, int32 or uint32 schemas. The pinned kernel permits an underscore only as the first character of an operand name: snake_case names such as unit_price are rejected before replay (kernel limitation #4491). Missing accumulators start at zero; missing/null numeric event fields contribute zero. Zero arithmetic leaves an absent accumulator absent, while public reads return the schema’s zero default. Double arithmetic retains binary floating-point precision, including rounding at large magnitudes. Int32 operands round ties to even, reject out-of-range conversions, then use unchecked wrapping arithmetic. Scalar initial values, independent sources and removal/recreation are supported. An aggregate-only event suppresses AutoMap; mixing arithmetic with an explicit scalar assignment restores normal AutoMap.
Arithmetic outside that shapeRejected before replay: uint32 or other accumulator formats, nullable/non-numeric operands or targets (including numeric strings), nested/context/literal operands, clearing an accumulator (including a text assignment that could clear it), null initial numeric values, custom/constant keys, and arithmetic combined with children or joins. Arithmetic inside children or fromEvery remains unsupported. Invalid seeded numeric values and out-of-range int32 operand conversions fail replay rather than silently coercing. Use a kernel-backed test.
Initial projection values; empty mappings; removedWith; removal/recreationFixture-backed scalar initial values (matching JSON kind, finite numeric range, canonical lowercase GUIDs, or null for nullable strings); unmapped array and object schema members are supported with either empty or scalar non-empty initial state (empty state seeds an empty array; non-empty state omits the array; both omit the object). Initial object, array and date-time values require a kernel-backed test. Each key gets its own initial state, reapplied on recreation; unrelated events do not resurrect a removed model. An event subscribed through both From and RemovedWith requires a kernel-backed test.
Children: @childrenFrom (including the { childType, key, identifiedBy, parentKey } options form) and fluent .children(...) with .identifiedBy(...), .from(...).usingKey(...) and .removedWith(...).usingKey(...)One level of children in an array-of-objects property, keyed by a string event property into a direct identifying property, under the event source’s parent (no parent key, or $eventSourceId). A child event adds the child when its key is new and otherwise updates that child in place; a child removal removes the matching child and ignores an unknown key. An event handled by both the root and a child collection applies both. The first event that only a child collection handles creates the parent uninitialized (just id and the collection); the parent’s initial values are filled in by the next event for that key, and a child removal for an unknown parent creates such a parent with an empty collection. Child AutoMap is schema-driven: in both decorator modes, a child type supplied through @childrenFrom or @field with resolvable runtime members produces a typed item schema that maps same-named scalar event properties. With legacy decorators, an unknown child type, a child with no resolved properties, or a child whose resolved properties omit its identifier produces an untyped item schema ({ "type": "object" }) and stores only the identifier. Pass childType and make its members discoverable with initializers or @field to map the child’s properties; partially resolved children stay typed if their identifier resolves. AutoMap into the child identifier is supported only when its sole source is that entry’s child key. Typed legacy children now intentionally reject non-string identifiers (for example, id = 0) and incompatible AutoMap types that an untyped item schema previously left unchecked. Explicit child property mappings other than the identifier are not supported. Fixtures: children-from-keyed, children-identified-removed, children-typed-items, children-untyped-items, children-identifier-equals-key.
Children outside that shape: child keys from $eventSourceId, context, constants, composites or non-string properties; parent keys from event properties; a child identifier that is missing, nested, not a plain string, mapped from anything but the child key, or AutoMapped from a different or ambiguous source; disabled child AutoMap or child AutoMap exclusions; nested children or nested projections inside children; explicit child property mappings (renames, event context, constants, nulls, arithmetic); children combined with initial model state or root arithmetic; child joins, removedWithJoin, fromEvery/all and FromEventProperty value children; one event in several children operations; an event that both removes and changes the parent or a childRejected before replay with UnsupportedProjectionOperation naming the Children.<property> contract path: use a kernel-backed test.
Root joins: fluent .join(Event, join => join.on(...).set(...).to(...)) and model-bound @join(Event, 'foreignKey', 'eventProperty')One join event type, keyed by its $eventSourceId, matching a direct non-id read-model property populated by a root From mapping (explicit or AutoMap). Read-model properties and mapped event properties must be plain strings. Supports latest-prior-event backfill, later updates to every matching root, foreign-key changes, and root removal/recreation without deleting joined history. Join events never create a row by themselves. AutoMap respects explicit destinations, already-consumed source properties and noAutoMap exclusions.
Joins outside that shapeRejected before replay: multiple joins, join-only projections, joins without effective mappings, initial values, non-string/nullable/formatted schemas, context/constant/nested expressions, custom join keys, identifier joins, a join mapping its own join target, overlapping root/join property mappings or event types, removedWithJoin, joins inside children, and joins combined with children or arithmetic. Use a kernel-backed test.
.fromAll(...) / @fromAllEvents(...) and .fromEvery(...) / @fromEvery(...) / @fromAll(...)Rejected before replay with UnsupportedProjectionOperation: use a kernel-backed test. .fromAll(...) and @fromAllEvents(...) subscribe to every event type, including undeclared types; .fromEvery(...), @fromEvery(...), and the deprecated @fromAll(...) alias only map events already subscribed to. Contrary to the earlier ‘next major’ announcement, @fromAll behavior will not change. Opting into @fromAllEvents, not upgrading alone, changes the definition and may trigger replay.
Nested projections, variants, derivatives, custom/composite/constant root keys, dynamic destinations, passive projections, non-default event sequences, root FromEventPropertyNot supported: use a kernel-backed test.
Unsupported numeric formats; incompatible mapping types; multi-generation event-type history; derived context functions; migrations, compliance, scheduling, storageNot simulated: use a kernel-backed test. Compliance/security metadata anywhere in the read-model schema (including id, defaulted, nested, and unmapped members) is rejected before replay.

A root From event materializes an identifier-only model even if it changes no mapped properties. Missing content resolves to null but does not add an absent null-valued member; $null clears a previously populated member. Public reads omit null fields and apply the kernel’s schema defaults (zero, false, empty GUID, and minimum date-time) to missing non-nullable scalar fields; supported non-empty scalar initial state is schema-converted before mappings run; object-shaped initial values are rejected until fixture-backed. The in-memory sink’s typed key populates lowercase id; phase 1 rejects mappings that write that sink-managed property. A seeded event with the same ID but a different generation from the subscribed event is rejected before replay. Identifiers whose text would canonicalize to a different key (for example 01 as a numeric key or mixed-case GUID text) are rejected to avoid merging distinct sources.

Arithmetic fixtures cover all five operations, zero and absent values, null numeric inputs, initial values, fractional and large doubles, int32 rounding and wraparound, operand-conversion failures, AutoMap suppression and removal/recreation. They do not prove arithmetic combined with joins or storage-provider behavior.

The joins-* fixtures separately capture string-only root joins. Matching is case-sensitive. A later join event with a missing/null string leaves existing joined properties unchanged; backfilling that same event from a root From clause can clear a populated property. Backfill runs only for clauses that map the foreign key, not unrelated root updates. Removing a root does not remove source-event history, so recreation resolves the latest prior join event. These fixtures do not cover persistent storage, join scheduling or durable futures.

The evaluator is checked against committed, per-step fixtures from the pinned production Chronicle projection pipeline (Source/testing/projections/fixtures/); kind: oracleGuard describes kernel behavior outside the narrowed phase-1 surface (or oracle safety boundaries): the evaluator must reject these fixtures rather than reproduce their kernel snapshots. Failing oracle fixtures also assert the pinned kernel’s expected error. Run yarn oracle:check to detect drift and use the oracle’s update mode only when reviewing changes to the pinned kernel. A green in-process scenario is not evidence for persisted reads, observer scheduling, event migrations, compliance encryption, storage conversion, or advanced projection relationships. For broader projections, use ChronicleKernelScenario or a live-kernel test rather than the in-process evaluator.

Composing scenarios: read models over a shared event sequence

Section titled “Composing scenarios: read models over a shared event sequence”

ReadModelScenario.observe(source) makes a read-model scenario read the committed history of an EventScenario or a ReactorScenario instead of its own seeded events. Nothing is copied: every read (instance, instanceForEventSourceId, wasDeletedForEventSourceId) replays the source’s accepted history as it is at that moment, through the same reducer or validated projection that seeded events use. Append with the event scenario, then read the resulting models:

import { field } from '@cratis/fundamentals';
import { eventType, fromEvent } from '@cratis/chronicle';
import { EventScenario, ReadModelScenario } from '@cratis/chronicle/testing';
@eventType()
class TestingCompositionAuthorRegistered {
@field(String) name: string;
constructor(name: string) { this.name = name; }
}
@fromEvent(TestingCompositionAuthorRegistered)
class TestingCompositionAuthor {
@field(String) id = '';
@field(String) name = '';
}
const compositionEvents = new EventScenario({ artifacts: { eventTypes: [TestingCompositionAuthorRegistered] } });
const compositionAuthors = new ReadModelScenario(TestingCompositionAuthor).observe(compositionEvents);
await compositionEvents.given.forEventSource('author-1').events(new TestingCompositionAuthorRegistered('Ursula'));
await compositionEvents.when.forEventSource('author-2').events(new TestingCompositionAuthorRegistered('Octavia'));
const compositionAuthor = await compositionAuthors.instanceForEventSourceId('author-2');
if (compositionAuthor?.name !== 'Octavia') throw new Error('Expected the appended author');

ReactorScenario.appendedEvents exposes the reactor’s shared history: its given/when input events and any events a handler appends explicitly through services.eventStore.eventLog. A value a handler returns is recorded in produced/sideEffects, not appended, so it never reaches an observing read model; this matches the reactor section above. Observing is one-way and explicit. No observer is scheduled, and the read model does not feed back into the reactor.

A scenario reads either its own seeded events or one observed sequence: calling given...events on an observing scenario, observing after seeding, or observing twice rejects with UnsupportedProjectionOperation. An observed event with a non-default event source type or stream (from routed append or appendMany calls) also rejects on read, because no fixture establishes how projections treat routed events. Rejected appends are not in the accepted history, so they never reach the read model. Observed $eventContext(Hash) mappings reject events whose schemas contain numeric, Guid, date or object fields: those payloads can have scenario-local hashes rather than kernel-normalized hashes. Observing reducers receive client-serialized JSON, not kernel-normalized content, just like event history and sequence reads. Guid casing and date-time spelling can differ from production (for example Z versus +00:00, or dropped .000); compare Guids case-insensitively and dates as instants, not strings. Ordinary scalar mappings still apply the supported projection conversions to those serialized fields. Every other rule in Projection capabilities applies unchanged. Command recording and read-model snapshots taken from reactor services are not part of this composition; use a kernel-backed test for those.