foldkit-bundle-surface
v0.4.2
Published
foldkit-bundle placements as foldkit-surface Module contracts: each placement owns its Model path, validated with every other owner.
Readme
foldkit-bundle-surface
Puts foldkit-bundle placements into a
foldkit-surface Module, so each placement is an owner of its
Model path, checked against every other owner.
A placement changes one slice of the parent Model through the parent's
update. That makes it an owner in the same sense as a Sync projection or a
Remote store. Module.validate already reports two owners of one path; this
package gives placements a contract so the check includes them.
Search.at(link to model.search) -> contract bundle:Search@search owns search
TodoSync -> contract sync:Todos owns todos
Module.validate -> no overlap, every path and Message declaredInstall
pnpm add foldkit-bundle-surface foldkit-bundle foldkit-surface effect foldkitSixty seconds
import { Schema } from 'effect'
import { defineMessageUnion } from 'foldkit/message'
import { Bundle } from 'foldkit-bundle'
import { BundleSurface } from 'foldkit-bundle-surface'
import { Module, Surface } from 'foldkit-surface'
const Search = Bundle.make('Search', {
Model: Schema.Struct({ query: Schema.String }),
Message: defineMessageUnion({ Typed: { query: Schema.String } }),
init: () => ({ model: { query: '' } }),
update: (_model, message) => ({ model: { query: message.query } }),
})
const Searchbox = Bundle.declare(Search, 'search')
const Model = Schema.Struct({ ...Searchbox.fields })
const Message = defineMessageUnion({ ...Searchbox.cases })
const App = Surface.application({ Model, Message })
const Page = BundleSurface.parent(App)
const placements = Page.assemble(Page.at(Searchbox))
const AppModule = Page.module(placements, [])
Module.validate(AppModule) // []The Module now records one owner for search. Nothing has mounted or run:
validation inspects declarations. Pass other contracts as the second argument
to Page.module, then check the findings before starting the application.
Rendering a Surface over search only observes that owner.
BundleSurface.parent(app)isBundle.parentbuilt from the application's own Model and Message, withmodulebound to that application.Page.module(assembly, items)makes onebundlecontract per placement, brings each wiring's contract as-is, and adds your other contracts. It performs no I/O.BundleSurface.contract(app, placement)is one contract, forModule.makeorModule.add. It owns the placement's path, names the parent Message tags its Messages travel under, and lists its args asargsmetadata when the bundle has an args Schema or is abundle.withpreset.BundleSurface.link(fieldRef, wrapper)isLink.makeover a Surface field ref, for a custom placement. Its contract names the ref's application.BundleSurface.module(app, assembly, items)isPage.modulewithout a scope.
What the existing rules catch
| Mistake | Finding |
| --- | --- |
| Two placements on one path, or a placement on a path Sync or Remote owns | ownership-overlap |
| A placement through another application's field ref | foreign-contract |
| A wrapper variant missing from the application's Message | unknown-message |
| A placement's Link path that is not a Model field | unknown-path |
Module.manifest and Module.toMarkdown list each placement as the owner of
its path.
Reading a placement: projections and Mirrors
A placement's state is part of the parent Model, so its fields are already in
the application's ref tree. Observing one needs no bundle API:
App.model.search.query, App.model.search.select(projection).
For an application with search and filter placements, the following
integration fragment gives each mirror its own keys. Import Mirror from
foldkit-mirror; the first example above defines only search:
const SearchUrl = Mirror.url(App, {
name: 'search',
fields: [App.model.search.query],
keys: { query: { key: 'search.q' } },
})
const FilterUrl = Mirror.url(App, {
name: 'filter',
fields: [App.model.filter.query],
keys: { query: { key: 'filter.q' } },
})test/mirror.test.ts writes and reads both.
Why placements are not re-rooted Surfaces
A Surface names the Messages a feature may cause by their parent constructors,
and an agent adapter exposes one capability per constructor. Every Message of a
placement travels under one wrapper variant, GotSearchMessage({ message }),
so a re-rooted Surface could only offer that single variant, not the child's
individual Messages. Exposing a placement's Messages one by one needs Surface
and Agent to understand wrapped variants, which is a change to those packages
rather than a helper here. Until then, declare a parent Surface over the
placement's fields and name the wrapper variant in its messages.
Limits
- A Link composed with
Link.andThen(orLink.compose) from a ref-built Link names the application passed tocontract, not the ref's. - Surfaces cannot expose a placement's individual child Messages, for the reason above.
