Skip to main content

Data product pipelines

Pipelines are expressions assigned at module scope—usually as constants named flow, joined, result, or arbitrary identifiers—whose values are streams consumed by .publish(...). The type checker and runtime decide which methods exist on which stream types. The tables below map surface syntax to common patterns.

Publishing

ConstructTypical patternNotes
.publish(Outlet)Map or filter, then publishTerminal side effect to a wired outlet
.publish(KeyValueApi)Command or update stream to a KV APIStream of commands or typed updates
Re-publish same streamOne stream, two outletsBind a = …, then a.publish(A), a.publish(B)

Transformations on streams

Method / callTypical patternNotes
.map(closure)One-to-one transformMap each element
.filter(closure)Keep matching elementsPredicate
.filterMap(closure)Map and drop emptyOften returns Some/None via match
.filter then .mapTwo-step chain in one flowEquivalent to chained operators
.flatMap(closure)After a joinFlatten nested collections from join results
.forEach(closure)Side-effect per elementOften with {} body
.indexBy(key) then .map(r -> r.latestValue())Latest (or windowed) valueearliestValue, currentValue, and previousValue may be available depending on stream metadata
.aggregateBy(key, initial, reducer)Per-key stateful accumulationEmits AggregateUpdate<K,V> on every event; reducer returns Option<V> (None removes the key)
innerJoin(left, right, lk, rk)Equi-join on keysKey extractors return lists
com.dataspine.interleave(A, B)Merge two ingestsNamespace function on two streams
Join result .map / inner .mapNested map on join rowsNested jr.map on pair

Key-value commands

ConstructTypical use
KeyValueApiCommand.Upsert(key, value)Upsert into a KV API
Ingest typed as KeyValueApiCommand<K,V>Command stream including deletes

aggregateBy — keyed stateful accumulation

DataStream<T>.aggregateBy<K, V>(
keyMapper: (T) => K,
initial: () => V,
reducer: (V, T) => Option<V>,
): DataStream<AggregateUpdate<K, V>>

For each input event the operator:

  1. Extracts the key with keyMapper.
  2. Looks up the current accumulator in a per-key store, or falls back to initial() if the key is unseen.
  3. Calls reducer(accumulator, event) to produce the next value.
  4. Some(v) → store the updated accumulator and emit an update.
    None → remove the key from the store (tombstone) and emit a deletion.

Emitted shape

Struct AggregateUpdate<K, V> {
key: K,
value: AggregateChange<V>,
}

Enum AggregateChange<V> {
Added(V), // first event for this key
Changed { previous: V, current: V }, // key existed and was updated
Deleted(V), // reducer returned None; V is the prior value
}

Silent no-op: if reducer returns None for a key that was never added, nothing is emitted.

Accessor methods

ExpressionReturn typeDescription
update.key()KThe key
update.value()AggregateChange<V>The change variant
change.currentValue()Option<V>Some(v) for Added/Changed, None for Deleted
change.previousValue()Option<V>None for Added, Some(v) for Changed/Deleted

Example — running total per user

Struct Event { id: String, amount: Int64 }
Struct Total { sum: Int64 }

flow = EventIngest
.aggregateBy(
event -> event.id,
() -> Total { sum: 0 },
(acc, event) -> Some(Total { sum: acc.sum + event.amount })
)
.filterMap(update -> update.value().currentValue())
.publish(TotalOutlet)

Example — delete on sentinel event

flow = EventIngest
.aggregateBy(
event -> event.id,
() -> Total { sum: 0 },
(acc, event) -> if event.close then None
else Some(Total { sum: acc.sum + event.amount })
)
.filterMap(update -> update.value().currentValue())
.publish(TotalOutlet)

When event.close is true the key is removed from the store. A subsequent event for the same key starts fresh from initial().

Why not indexBy?

indexBy stores the entire input event keyed by an ID and emits IndexOperationResult<K, T>. aggregateBy stores a derived accumulator of a potentially different type V and applies a user-provided reduction on every event — use it when you want running sums, counts, or any stateful reduction rather than a direct key-value index of raw events.

Types that often appear in pipelines

TypeRole
JoinTrigger<K,V>Join keys and change metadata
JoinResult<T> / List<JoinResult<...>>Rows produced by innerJoin
com.dataspine.json.ValueJSON-heavy or semi-structured payloads

Ordering and emptiness

TopicMeaning
Multiple messagesOrder of ingests affects downstream snapshots when testing step-by-step
Empty ingestProducts should define behaviour when no rows arrive

Exact generics and error messages come from dataspine check; this page focuses on syntax patterns for flows.