Bundle / foldkit-bundle

Composing a parent

Bundle.compose states a parent once: its own fields, as Schema.Struct takes them, and then, through pipe, its own Messages and the bundles it places.

ts
const App = Bundle.compose({ greeting: Schema.String }).pipe(
  Bundle.withMessages({ ClickedReset: {} }),
  Bundle.withChild('hello', HelloForm, {
    onOut: out => model => ({ model: { ...model, greeting: `Hello, ${out.name}!` } }),
  }),
)
StepWhat it adds
Bundle.compose(fields)the parent's own Model fields
Bundle.withMessages(cases)the parent's own Message cases, as defineMessageUnion takes them
Bundle.withChild(field, bundle, config?)the bundle under field, and its wrapper Got<Field>Message
Bundle.withEach(field, bundle, config?)the bundle once per key of a record under field
Bundle.withWiring(...wirings)an integration's wiring (Remote, Mirror, Sync, Agent)
Bundle.withServices<S>()the services the parent's own update may require
Bundle.configure(field, config)the config of a child added without one

The result has App.Model and App.Message, the Schemas to give Surface.application or the runtime; App.children.hello, the placement, with its view and helpers; and App.placements, the assembly, with initial, update, subscriptions and complete. The config of withChild and withEach is what Page.at and Page.each take below: args when the bundle has them, onOut when it has an OutMessage, and key or when.

Each step is typed by the parent as it is at that step. An onOut sees the parent's own fields and every child placed so far, so a mistake is reported where it is written, and a child can read an earlier one. Write its result from the Model it is given, spreading it or through modifyFields, so the fields of children placed later are kept. A field or a Message case given twice is refused: in the types for a field, and when the composition is built for both.

A wiring is usually made from the parent's own Model, through a Surface application and a Remote domain built from App.Model, so it joins in a second pipe once those exist:

ts
const Wired = App.pipe(Bundle.withWiring(Data.wiring({ board: BoardSurface })))

A child's config can be made from the parent too: a Crud editor's onOut comes from Editor.at({ data, model: App.model.editor }), which needs the Surface application built from this very Model. Add such a child without its config, build what needs the Model, and give the config with Bundle.configure:

ts
const Base = Bundle.compose({ greeting: Schema.String }).pipe(Bundle.withChild('hello', HelloForm))
const greet =
  (out: typeof Greeted.Type) =>
  (model: typeof Base.Model.Type) => ({ model: { ...model, greeting: `Hello, ${out.name}!` } })
const Page = Base.pipe(Bundle.configure('hello', { onOut: greet }))

Base.Model and Base.Message exist at once, for Surface.application. children and placements do not until every child that needs args or an onOut has them: reading either earlier is a type error naming the children still waiting. configure is typed by the child it configures, and refuses a field that is not a child or one given its config already.

What it builds, and when to build it by hand

compose is sugar over the primitives below, and produces the same values. The counter above, by hand:

ts
const Clicks = Bundle.declare(Count, 'clicks')
const Model = Schema.Struct({ greeting: Schema.String, ...Clicks.fields })
const Message = defineMessageUnion({ ...Clicks.cases })
const Page = Bundle.parent({ Model, Message })
const placements = Page.assemble(Page.at(Clicks))

Use the primitives when the parent's Model and Message already exist elsewhere, when a placement needs a custom Link (a nested path, an Option field, a gate on the Link), or when a declaration is shared between modules. The rest of this README uses them, because each step there is one idea.