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 situation
Bags are scanned into a bin as they come off the line. The bin ships when it holds twelve bags or when four hours pass, whichever comes first. Nobody knows in advance which bags will go in.
The recipe
import { conditionalAccumulator } from '@hotmeshio/long-tail';
const bin = await conditionalAccumulator<BagV1, { shippedBy?: string }>(signalId, { role: 'bin', description: 'Scan each bag into the bin', metadata: { binKey }, accumulate: { max: 12 }, timeout: '4h',});if (bin === null) return cancelled(binKey);ship(bin.$accumulated.map((item) => item.itemKey), bin.$trigger);await lt.escalations.accumulateByMetadata({ key: 'binKey', value: binKey, itemKey: orderId });// { outcome: 'accepted', count: 3, remaining: 9, escalationId }$accumulated lists each entry’s itemKey, payload, at and actor in arrival order. $trigger says what ended the wait: count, timeout or resolve.
The guarantee
Each add is one guarded statement: the entry lands only while the row is pending and the key is not already held, and accumulate_count is recomputed in the same statement. The add that reaches max resolves the row and wakes the wait. Two adds racing for the last slot yield one completed and one refusal. See Batches and accumulators.
Related patterns
The escalation boundary
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 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
Reciprocal add
A bag joins a bin and the bin records the bag in one statement. Both rows change or neither.
The escalation boundary
Find a container by held key
Which bin holds order ORD-9 is one GIN-indexed containment query on the bin rows. No side table tracks membership.
