Primitives / Time

debounce and Throttle

debounce({ name, value }) is a factory over any value Schema. Changed restarts a delayMs timer, and only the latest value settles, as an OutMessage the placement must handle with onOut, so a settled search query can never be dropped by omission:

ts
import type * as Update from 'foldkit/update'
import { debounce } from 'foldkit-primitives/time'

const Query = debounce({ name: 'Query', value: Schema.String })

const Base = Bundle.compose({ results: Schema.Array(Schema.String) }).pipe(
  Bundle.withMessages({ Found: { results: Schema.Array(Schema.String) } }),
  Bundle.withChild('query', Query),
)
type SearchModel = typeof Base.Model.Type
type SearchMessage = typeof Base.Message.Type

const fire =
  ({ value }: { readonly value: string }): Update.Step<SearchModel, SearchMessage> =>
  model => ({ model, commands: [search(value)] })

const Search = Base.pipe(Bundle.configure('query', { args: { delayMs: 300 }, onOut: fire }))

The onOut returns a Command whose Message is the parent's own Found, so it is written against Base.Model and Base.Message and given through Bundle.configure, as the Bundle README describes for a config made from the parent. An onOut that only writes the Model goes straight in withChild's config.

Each Changed bumps a generation the scheduled Settled carries; a superseded timer's fact arrives with an old generation and emits nothing.

Throttle is the leading edge to debounce's trailing one: the first Attempted in an intervalMs window surfaces Throttled { at } (through onOut, likewise required) and the rest are dropped. Attempted reads the clock through a Command, so the check runs on Effect time and tests drive it; the window boundary counts as past. Pair the two rather than adding a second timer.

Relative time

formatRelativeTime(from, to, locale?) picks the unit, seconds through years, and lets Intl.RelativeTimeFormat word it, so locales come from the platform rather than a phrase table. There is deliberately no now helper: Clock.currentTimeMillis already is it.

Failure

A non-positive or non-finite interval is rejected at placement. While stopped, a tick stream is empty. A superseded debounce timer emits nothing.