Projections
Projections are shared Chronicle read-model behavior. Use the shared docs for projection styles, model-bound projections, declarative projections, and client-tabbed examples.
- Projections
- Choosing a read model style
- Model-bound projections
- Declarative projections
- Composite keys
- TypeScript client setup
TypeScript client notes
Section titled “TypeScript client notes”The TypeScript client supports these projection capabilities, in addition to @fromEvent, @setFrom, and the declarative .from(...) builder:
- The model-bound arithmetic decorators —
@addFrom,@subtractFrom,@increment,@decrement,@count— and their fluent equivalents (.add()/.subtract()/.count()onIFromBuilder/IJoinBuilder). - The model-bound
@childrenFrom,@nested, and class- or property-level@clearWithdecorators, and the matching fluent.children()/.nested()builders (plus.addChild()/.setThisValue()onIFromBuilder/IJoinBuilder). On a scalar member of a root, child, or nested type,@clearWith(Event)maps that member to$nullin the event’sFromentry. On a nested type or the member carrying@nested, it clears the whole nested object instead. - Apply
@noAutoMapto a child or nested class to disable automatic property mapping for that definition; a decorated declaring class also disables it for its direct child and nested definitions (the policy does not cascade further). Apply@noAutoMapto individual properties on child or nested types to exclude just those members from automatic mapping. Explicit@setFrommappings still apply. Fluent child and nested builders support.noAutoMap()and.autoMap(). - Constant values with
@setValue(Event, value)and.set(...).toValue(value), and constant keys. Earlier client versions sent these in a form the kernel did not apply, so the read model kept its previous value. - Event-context mappings with
@setFromContext(Event, 'occurred'),.set(...).toEventContextProperty('occurred'), and context-derived keys with.usingKeyFromContext('eventSourceId')or.usingParentKeyFromContext('eventSourceId'), including inside.addChild(model => model.children, child => child.identifiedBy(item => item.id).usingKeyFromContext('sequenceNumber').usingParentKeyFromContext('eventSourceId')). In the accessor form of.addChild(model => model.children, event => event.property), the builder namesidentifiedBy,usingKey,usingKeyFromContextandusingParentKeyFromContextare reserved and can’t be selected as event properties. These names use the TypeScriptEventContextspelling; the client sends$eventContext(Occurred)and$eventContext(EventSourceId)to the kernel. Registration accepts only paths the kernel can resolve: any rootEventContextproperty; an identity member ofcausedBy(subject,name,userName), orcausedBy.onBehalfOfas a whole identity; andoccurred.week(), the kernel’s only derived function, emitted asOccurred.Week().causationandtagsare collections, so they can be mapped only as a whole. Anything else fails during projection registration, naming the projection, read model and invalid path. The error’s cause isInvalidEventContextPropertyError, exported from@cratis/chronicleand@cratis/chronicle/projections. Earlier client versions sent expressions the kernel did not recognize, or treated model-bound context names as event-content properties. .usingCompositeKey()/.usingParentCompositeKey()on the fluentIFromBuilder/IJoinBuilder. Each part accepts an event property, event context property, event source id or constant through.set(target).to(...),.toEventContextProperty(...),.toEventSourceId()or.toValue(...). The existing.set(target, eventAccessor)form remains supported. See TypeScript composite keys and the shared composite keys guide.
Earlier client versions rejected these with a not implemented yet. error at registration. Upgrade the client if you still see it.
For model-bound @childrenFrom, an automatically mapped child whose identifier is a discoverable id/Id, or supplied as identifiedBy, gets its identifier from $eventContext(EventSourceId) when the child key defaults to the event source id. An explicit event key instead maps to that event property unless it already matches the identifier name; if no id is found, a child property matching the event key becomes IdentifiedBy. TypeScript has no [Key] decorator: pass identifiedBy for a differently named identifier, and use an initialized property or @field so convention-based id/Id is discoverable at runtime. Explicit identifier mappings and class-level @noAutoMap on the child or its declaring class take precedence; property-level @noAutoMap on the identifier does not suppress this default identifier mapping. Creating events with non-empty mappings consisting only of aggregate expressions (@count, @increment, @decrement, @addFrom, @subtractFrom) do not get the identifier mapping: the kernel initializes the child identifier from the resolved key, and leaving those mappings aggregate-only prevents unrelated same-named event properties from being AutoMapped onto the child.
For existing apps with model-bound children, this changes two behaviors: an automatically mapped identifier now comes from the event source id rather than an AutoMapped same-named event property when the child key is implicit, and a child without id whose property matches an explicit event key now uses that property as IdentifiedBy. When @count, @increment or @decrement give an aggregate-only creating event a constant key, the kernel initializes the identifier from that constant ($value(...)), the key it uses to find the child. The .NET model-bound builder instead adds an explicit identifier mapping from the event source id for the default child key, even for aggregate-only events. Replaying affected events with the new definitions can produce different persisted child data; check those read models when upgrading.
Under runtimes that emit no type metadata, such as tsx, name the child type of a children collection in @childrenFrom(ItemAdded, Item, 'itemId'): the second argument can be the child class instead of the key. The client then excludes Item from the root read models and restores the collection as Item instances. In this form the child type takes the key’s place, so 'itemId' is the identifiedBy property and the child key is always the event source id, which also becomes the identifier. To name the child type and key children by an event property, use the options form, which accepts the same key, identifiedBy and parentKey as the .NET [ChildrenFrom<TEvent>] attribute:
@childrenFrom(ItemAdded, { childType: Item, key: 'itemId', identifiedBy: 'id', parentKey: 'orderId' })items: Item[] = [];Every option is optional; an omitted key or parentKey defaults to the event source id, and identifiedBy is discovered as described above. In both decorator modes, a child type with resolvable runtime members registers a typed item schema so AutoMap maps the child’s properties, not just its identifier. With legacy decorators, the item schema falls back to { type: 'object' } if no child properties resolve or the resolved properties do not include the child identifier. Members declared only with ! have no runtime type under tsx/esbuild; initialize them or use @field to make them discoverable. Partially resolved children remain typed when their identifier resolves. TypeScript’s emitted design:type only identifies the collection as an array, not its element type. With legacy decorators and no element-type metadata, pass childType; without it, the item schema remains untyped and AutoMap stores only the identifier. Declaring the element type with @field(Array, { genericArguments: [Item] }) and using @childrenFrom(ItemAdded, 'itemId') still works and produces the same definition. The projections/model-bound/children/child-type-with-key client snippet shows a complete, compiled example.
With standard decorators, a model-bound read model whose mappings are all on properties registers only once an instance of it exists. Give it a class-level @fromEvent(...) decorator so it registers when its module loads.
All-event mappings on children and nested objects
Section titled “All-event mappings on children and nested objects”@fromAllEvents maps a property on the object where you declare it. On a child or nested type, it populates that object’s property, including on the event that creates it. Root and child properties with the same name can map from different event properties without overwriting each other’s mappings. @fromEvery on a child or nested property also stays on that object and applies to its handled events.
Only a root @fromAllEvents enables sequence-wide subscription for the root projection. A child annotation does not subscribe the root to unrelated events. Root mappings still run on child events when the root mapping includes them, but root values are never copied into child objects.
Earlier TypeScript clients sent child and nested @fromAllEvents mappings as bare root property names. Chronicle 19.78.1 can recover those mappings when the root schema does not declare the same property. Upgrade the client when both levels declare it, then replay affected projections to rebuild previously materialized child values. Child-local mappings use the existing child definition contract; no new contract package is required.
Projection tags
Section titled “Projection tags”Apply @tag('Analytics') or @tags('Analytics', 'Reporting') to a declarative projection class, or to the read model class for a model-bound projection. Import either decorator from @cratis/chronicle. Both decorator modes support repeated declarations; duplicate values are emitted once, sorted for deterministic definition hashing. For declarative projections, only tags on the projection class are sent, not tags on its read model.
The client sends the tags with the projection definition. Chronicle does not store projection tags yet: its registration converter drops them (through at least 19.29.1), so they are not stored or returned; see Chronicle issue #4512. Tags do not filter received events or change in-process ReadModelScenario evaluation. See Tagging projections for client examples.
Projection registration computes LastUpdated from the full definition, including tags and nested mappings. After upgrading, this value changes once for every projection. The Chronicle kernel’s ProjectionDefinitionComparer excludes LastUpdated, ReadModel, and InitialModelState before comparing definitions (and normalizes optional collections). A changed LastUpdated alone therefore does not trigger a replay; changed child mappings or IdentifiedBy are part of the comparison.
Once the kernel preserves tags, they are part of its definition comparison: reordering tags changes nothing, but adding, removing, or changing a tag is a definition change that can replay the projection or create a replay recommendation, depending on the kernel’s definition-evolution policy. For projections that already carried @tag/@tags, this also applies to the first registration after upgrading to a kernel that preserves tags.