A timeout is a delivery window
On a container, the timer closes the window and delivers what it holds. The type has no false branch to handle.
The situation
A consolidation bin collects whatever arrives in a two-hour window. When the window closes, the bin ships with what it has. A timeout here is the normal end, not a failure.
The recipe
const bin = await conditionalAccumulator<BagV1>(signalId, { role: 'bin', metadata: { binKey }, accumulate: {}, timeout: '2h',});if (bin === null) return cancelled(binKey);
switch (bin.$trigger) { case 'timeout': return shipWindow(binKey, bin.$accumulated); case 'resolve': return shipEarly(binKey, bin.$accumulated); case 'count': return shipFull(binKey, bin.$accumulated);}With no max, only the timer or a manual resolve ends the wait. conditionalAccumulator returns AccumulatorResult | null: false is not in the type.
The guarantee
| Path | $trigger |
Row status |
|---|---|---|
Add that reaches max |
count |
resolved |
| Timer fires first | timeout |
expired |
| Manual resolve | resolve |
resolved |
| Cancel | none, returns null |
cancelled |
On every path except cancel, the row stores exactly what the workflow received. The expiry statement and every resolve re-check pending under lock, so the timeout settles once. See Batches and accumulators.
Related patterns
The escalation boundary
Open container
One row is a container that fills over time. Items join one at a time and the workflow wakes once with the ordered collection.
The escalation boundary
Count trigger versus cap
max either ends the wait when the container fills or only limits how much it can hold. One flag, resolveAtMax, picks which.
The escalation boundary
Deliver what arrived on timeout
With partialOnTimeout, a batch that misses its deadline wakes with the items filled so far instead of false, so the workflow can act on them.
The escalation boundary
Answer, timeout or cancel
Every wait ends in one of three typed values: the answer, false on timeout, or null on cancel. Each branch is explicit in code.
