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

  1. Ports regenerated from settings — the port list changes (router adding one output per configured route). Each port still has a Go type in Configuration.
  2. A different Go type per state — the port keeps its name but its Configuration value changes type with component state (ticker's Start/Stop control).
  3. 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 Configuration stays 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 propertyOrder on 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 calls schema.CreateSchema (which returns a swaggest jsonschema.Schema) and marshals it, returning nil on 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

  1. Keep Ports() cheap and pure — it is called on every reconcile; derive it from current state, don't do I/O.
  2. Guard shared statePorts() and Handle run concurrently; protect state read by getControl()-style helpers with a mutex.
  3. Keep OnSettings idempotent — the runtime may re-deliver identical settings (it does so on a TTL when settings contain secret placeholders).
  4. Publish example data, not placeholders — a Configuration value populated with realistic sample data lets downstream edges validate at build time instead of resolving to null at runtime.

Next Steps