Bundle / foldkit-bundle

Args, OutMessages, and helpers

update receives the args as its third parameter, so behaviour can depend on configuration without storing it in the Model. This counter emits an OutMessage when it reaches its limit. CounterModel, CounterMessage, and the LimitReached Schema are ordinary Schemas:

ts
const Counter = Bundle.make('Counter', {
  Model: CounterModel,
  Message: CounterMessage,
  args: Schema.Struct({ limit: Schema.Number }),
  init: () => ({ model: { count: 0 } }),
  update: (model, _message, { limit }) => {
    const count = model.count + 1
    return count === limit
      ? { model: { count }, outMessage: LimitReached.make({ count }) }
      : { model: { count } }
  },
  helpers: {
    reset: (model: CounterModel, to: number) => ({ model: { ...model, count: to } }),
  },
})

A bundle with an OutMessage must be placed with onOut, which folds it into the parent as a Step. The Step sees the parent with the child already written back, and its model is typed from the scope:

ts
const Clicks = Bundle.declare(Counter, 'clicks')
const CounterPage = Bundle.parent({
  Model: Schema.Struct({ ...Clicks.fields, reached: Schema.Number }),
  Message: defineMessageUnion({ ...Clicks.cases }),
})

const ClicksPlaced = CounterPage.at(Clicks, {
  args: { limit: 10 },
  onOut: outMessage => model => ({ model: { ...model, reached: outMessage.count } }),
})

ClicksPlaced.helpers.reset(0) // an Update.Step of the parent
  • args is required when the bundle takes them, and checked against the args Schema when placed (or when a preset is made with with); a value that bypassed the types throws naming the placement.
  • onOut is required when the bundle has an OutMessage, so one cannot be dropped by omission. Write onOut: Bundle.ignore to drop it on purpose.
  • Without an args Schema, Args is inferred from init's parameter annotation instead.

Derived args: args from the parent seed

args is a static value or a factory from the parent seed: the fields assembly.initial(rest) was given, before any placement initialised. A child whose initial state depends on what only the parent knows at startup — the route, the authenticated user, the workspace — derives it instead of taking a fixed value:

ts
const people = Page.at(PeopleDeclared, {
  args: parent => ({
    searchText: searchFromRoute(parent.route),
  }),
})

assembly.initial({ route: urlToAppRoute(url) })

The seed is rest plus empty collections, with no placement's fields in it. The factory's parameter omits its own field, so reading it is a type error; sibling fields are consistently absent at runtime, so read only seed fields and placement order never matters. The result is checked against the bundle's args Schema, naming the placement, then retained for update, helpers, Subscriptions, and resources. Subscriptions and resources that ignore args are readable before initial() runs; ones built from a factory need it (or runtime()) first, and name the placement when read too early. A factory runs once per seed it has seen: repeat initializations over equal seeds reuse the retained args, so one runtime flow derives once for its records and once is enough for its init. It never re-runs against live state, so keep it pure of its seed — return the same args for the same seed, and generate ids outside it. A factory on an optional child is skipped when rest starts the child as None. On a Model no initialization produced, update derives per use without retaining, so hand-built Models in tests fold with their own seed.