Dynamic Schemas
Some components need schemas that change based on configuration or runtime conditions. TinySystems supports this through the Ports() method, which the runtime re-reads whenever the node reconciles — and, for shapes with no Go type at all, through the Port.Schema seam.
Three Mechanisms
- Ports regenerated from settings — the port list changes (router adding one output per configured route). Each port still has a Go type in
Configuration. - A different Go type per state — the port keeps its name but its
Configurationvalue changes type with component state (ticker's Start/Stop control). Port.Schema(runtime-authored) — no Go type exists at all; the component publishes raw JSON Schema bytes verbatim.
All three go through Ports(); nothing else has to change.
1. Dynamic Ports from Settings
The router regenerates its output ports from settings (simplified from the real common-module router):
type Settings struct {
Routes []string `json:"routes" required:"true" title:"Routes" minItems:"1" uniqueItems:"true"`
EnableDefaultPort bool `json:"enableDefaultPort" required:"true" title:"Enable default port"`
}
type Component struct {
settings Settings
}
// OnSettings (module.SettingsHandler) fires when settings arrive; the
// runtime then re-reads Ports() and republishes the node status.
func (t *Component) OnSettings(_ context.Context, msg any) error {
in, ok := msg.(Settings)
if !ok {
return fmt.Errorf("invalid settings")
}
t.settings = in
return nil
}
func (t *Component) Ports() []module.Port {
ports := []module.Port{
{
Name: v1alpha1.SettingsPort,
Label: "Settings",
Configuration: t.settings,
},
{
Name: "input",
Label: "IN",
Position: module.Left,
Configuration: InMessage{},
},
}
// One output port per configured route
for _, r := range t.settings.Routes {
ports = append(ports, module.Port{
Name: "out_" + strings.ToLower(r),
Label: r,
Source: true,
Position: module.Right,
Configuration: new(OutMessage),
})
}
if t.settings.EnableDefaultPort {
ports = append(ports, module.Port{
Name: "default",
Label: "Default",
Source: true,
Position: module.Bottom,
Configuration: new(OutMessage),
})
}
return ports
}
2. A Different Go Type per State
The ticker's _control port advertises a Start button when stopped and a Stop button when running — two distinct Go types, chosen at Ports() time:
// ControlStopped is the _control schema when the ticker is not running.
type ControlStopped struct {
Context Context `json:"context" title:"Context" configurable:"true"`
Status string `json:"status" title:"Status" readonly:"true"`
Start bool `json:"start" format:"button" title:"Start" required:"true"`
}
// ControlRunning is the _control schema when the ticker is running.
type ControlRunning struct {
Context Context `json:"context" title:"Context" readonly:"true"`
Status string `json:"status" title:"Status" readonly:"true"`
Stop bool `json:"stop" format:"button" title:"Stop" required:"true"`
}
func (t *Component) getControl() interface{} {
if t.isRunning() {
return ControlRunning{Status: "Running", Stop: true}
}
return ControlStopped{Status: "Not running", Start: true}
}
func (t *Component) Ports() []module.Port {
return []module.Port{
// ...
{
Name: v1alpha1.ControlPort,
Label: "Control",
Source: true,
Configuration: t.getControl(), // type varies with state
},
}
}
Pushing the New State
After a state change, emit the fresh control value so the dashboard widget updates (the Emit helper comes from embedding module.Base, or from EmitterAware.OnEmitter):
t.Emit(context.Background(), v1alpha1.ControlPort, t.getControl())
To force the runtime to re-read Ports() and republish schemas (e.g. after a structural change), emit to the reconcile port:
t.Emit(context.Background(), v1alpha1.ReconcilePort, nil)
3. Port.Schema — Runtime-Authored Schemas
When the schema itself is data — a component that receives a JSON Schema at runtime and presents it to a human — there is no Go type to reflect. Set Port.Schema (a json.RawMessage) and the runtime publishes those bytes verbatim instead of reflecting Configuration.
The ask component is the canonical example: its form is authored in settings and published as the _control port's schema:
func (c *Component) form() json.RawMessage {
if c.settings.Form != "" {
return json.RawMessage(c.settings.Form)
}
return json.RawMessage(defaultForm)
}
func (c *Component) Ports() []module.Port {
return []module.Port{
// ...
{
Name: v1alpha1.ControlPort,
Label: "Control",
Source: true,
Position: module.Top,
// Configuration is a map so an untyped submission has something
// to decode into; Schema is the authored form, published verbatim.
Configuration: c.control(), // map[string]interface{}
Schema: c.form(),
},
}
}
Rules for Port.Schema:
- Leave it nil for typed ports. Reflection of
Configurationstays the default and is unaffected. Mechanisms 1 and 2 above never need it. - Do not rely on key order. A configurable overlay re-encodes the node and sorts its keys, so the order you write is not the order displayed. Set
propertyOrderon each field — that is what the editor sorts on (on reflected schemas the SDK stamps it for you; in hand-written schemas it is your job). schema.FromGo(val)is the bridge when you have a Go value but need the bytes — it callsschema.CreateSchema(which returns a swaggestjsonschema.Schema) and marshals it, returningnilon error so the runtime falls back to reflection.
Handle Signature
Business ports still arrive at Handle; system ports dispatch through the capability interfaces (SettingsHandler, ControlHandler, ReconcileHandler):
func (t *Component) Handle(ctx context.Context, handler module.Handler, port string, msg any) module.Result {
in, ok := msg.(InMessage)
if !ok {
return module.Fail(fmt.Errorf("invalid message on port %q", port))
}
// emit downstream; chain the Result so blocking callers get the
// synchronous response back
return handler(ctx, "output", OutMessage{Context: in.Context})
}
Best Practices
- Keep
Ports()cheap and pure — it is called on every reconcile; derive it from current state, don't do I/O. - Guard shared state —
Ports()andHandlerun concurrently; protect state read bygetControl()-style helpers with a mutex. - Keep
OnSettingsidempotent — the runtime may re-deliver identical settings (it does so on a TTL when settings contain secret placeholders). - Publish example data, not placeholders — a
Configurationvalue populated with realistic sample data lets downstream edges validate at build time instead of resolving to null at runtime.
Next Steps
- Schema from Go - Struct-based schemas
- Configurable Overlay - How edge schemas are built
- Settings and Configuration - Settings handling