Composite keys
When an event property alone does not identify a read-model instance, combine it with context or a fixed category. Each part of a TypeScript composite key uses the same set-expression builder as a projection property.
Configure the parts
Section titled “Configure the parts”Inside a declarative projection, use usingCompositeKey<TKey>() for the instance key. usingParentCompositeKey<TKey>() emits a child’s parent key, subject to the kernel limitations below. The following excerpt assumes the CompositeRecordChanged, CompositeRecordKey and read-model types in the complete expression-parts example:
builder.from(CompositeRecordChanged, from => from .usingCompositeKey<CompositeRecordKey>(key => key .set(target => target.orderId, event => event.orderId) .set(target => target.subject).toEventContextProperty('subject') .set(target => target.sourceId).toEventSourceId() .set(target => target.category).toValue('orders')));The event’s order id, subject and source id determine the instance, while orders supplies a fixed category. Repeated events with those same values update the same instance; changing the subject or source id selects another instance.
| Part source | Configuration after set(target) | Wire expression |
|---|---|---|
| Event property | .to(event => event.orderId) | orderId |
| Context property | .toEventContextProperty('subject') | $eventContext(Subject) |
| Event source id | .toEventSourceId() | $eventSourceId |
| Constant | .toValue('orders') | $value(orders) |
The existing two-argument set(target, eventAccessor) remains available and can be mixed with the new form. Every to method returns the composite builder for chaining. Complete each one-argument set with a to method before building the projection; an unfinished part throws instead of emitting an invalid key. toValue requires the selected property’s TypeScript type: a numeric part cannot take a string constant.
Setting the same target part twice now throws immediately, including when its first set is unfinished. This surfaces an invalid configuration earlier, matching .NET’s duplicate-property check; the kernel’s composite parser also rejects duplicates.
Context paths use TypeScript spelling, including causedBy.subject; the client emits CLR-cased paths such as $eventContext(CausedBy.Subject). The syntax uses parentheses, not $eventContext.Subject. Constants use the same validation and serialization as other toValue mappings, including $null for null. Characters the kernel cannot parse in a constant, such as commas, are rejected.
The example emits:
$composite(orderId=orderId,subject=$eventContext(Subject),sourceId=$eventSourceId,category=$value(orders))Parent and join keys
Section titled “Parent and join keys”usingParentCompositeKey accepts the same expressions as the .NET client, but Chronicle 19.26.2 does not generally resolve composite parents. With a root From subscription and a parent key combining content, context and source id without a constant, the first child can be deferred even though its parent exists. The kernel searches for a parent event using the composite object’s string representation, then falls back to looking for an existing child through items.id; neither locates that first child’s parent. Chronicle #551 tracks composite parent resolution. The client does not reject these definitions, preserving .NET parity.
A $value(...) part changes that path in 19.26.2: the kernel’s unanchored constant-expression matcher classifies the entire composite parent expression as constant and bypasses parent-event lookup. The constant-bearing parent fixture succeeds through this shortcut, not through general composite-parent support. Do not rely on adding a constant as a supported workaround.
Chronicle 19.26.2 ignores custom root join keys: backfill and live updates still match the join event’s source id (Chronicle #4165). Child joins do resolve custom composite key expressions. Chronicle 19.26.2 uses the resolved child-join key as both the root document key and the child identifier, so a child join only updates children whose root has the same identifier (Chronicle #4530). The oracle fixture deliberately uses matching composite root and child identifiers. Children are created through the $value parent-key shortcut; this fixture isolates child-join key evaluation, not parent resolution. A join’s parent-key methods have no wire effect because JoinDefinition has no parent-key field.
The in-memory sink replaces the matched root state with the join changes. This is a Chronicle in-memory sink bug: root properties and sibling children are dropped (Chronicle #4531), so in-memory results can differ from persistent storage.
Testing boundary
Section titled “Testing boundary”The in-process ReadModelScenario evaluator still rejects composite instance, parent and join keys before replay. Use kernel-backed tests for these projections. The packaged projection oracle covers root keys, the constant-bearing parent shortcut, constant-free parent deferral, ignored root join keys and resolved child join keys. The deferral fixture captures an attempted AddFuture through the oracle’s fail-closed futures guard, not a kernel exception or successful future storage. These fixtures do not prove persistent-sink behavior.
For the shared projection concepts and other clients, continue with the Chronicle composite keys guide.