The exact shapes that the refactor delivers. These are the things every
node converges on. Treat them as APIs.
Order: top-down — what a Node-RED user sees, what a node author writes,
what `generalFunctions` provides.
## 1. The Node-RED-visible contract per node
Every node exposes the same three Port shapes:
| Port | Direction | Carries |
|---|---|---|
| 0 | out | Process data — formatted via `outputUtils.formatMsg(..., 'process')` |
| 1 | out | InfluxDB telemetry — formatted via `outputUtils.formatMsg(..., 'influxdb')` |
| 2 | out | Registration / control plumbing |
| in | in | Commands routed by `msg.topic` through the `commands/` registry |
Every node also publishes a per-repo `CONTRACT.md` listing:
- Every `msg.topic` it accepts on Port 0 input, with the payload schema.
- Every `topic` shape it emits on Port 0/1/2.
- Every event its `measurements.emitter` fires for parents to subscribe.
- Every position label it expects from children.
This file is generated from the node's `commands/` module + a small
hand-written events section.
### Topic naming — canonical from Phase 1
`msg.topic` always uses one of these prefixes. `<noun>` and `<verb>`
are kebab-case after the dot (`set.flow-setpoint`, not
`set.flowSetpoint`).
#### Inputs — topics the node accepts on Port-0 input
| Prefix | Meaning | Idempotent? | Examples |
|---|---|---|---|
| `set.<noun>` | **Setter.** Replaces a state value with the supplied payload. Repeating with the same payload does nothing extra. | Yes | `set.mode`, `set.scaling`, `set.demand`, `set.inflow` |
| `cmd.<verb>` | **Imperative action.** Triggers a transition or sequence. Repeating triggers it again (or is rejected). | No | `cmd.startup`, `cmd.shutdown`, `cmd.estop`, `cmd.calibrate` |
| `data.<noun>` | **Bulk data input.** Sensor readings, measurement values, raw streams. The node consumes them. | n/a — values flow | `data.measurement`, `data.flow`, `data.pressure` |
| `child.<verb>` | **Parent/child plumbing.** Registration handshakes routed via Port 2. | n/a | `child.register`, `child.unregister` |
| `query.<noun>` | **Synchronous query.** The node responds on the same `msg` (or a sibling output). Used for read-only debug queries from a dashboard. | Yes (read-only) | `query.curves`, `query.cog`, `query.snapshot` |
#### Outputs — topics the node EMITS
| Prefix | Meaning | Where it appears |
|---|---|---|
| `evt.<noun>` | **Event.** A fact about something that just happened. Other nodes/dashboards subscribe to react. The node fires-and-forgets — no consumer is required. | `msg.topic` on Port 0 output, also fired internally on `this.emitter` so sibling modules can listen. |
`evt.*` is *one-way*: the node says "this happened", consumers can do
whatever they like with it. Examples: `evt.state-change` (state machine
moved), `evt.alarm` (a safety threshold tripped), `evt.calibrated`
(calibration completed). If you find yourself wanting to send a
command via `evt.*`, you actually want `set.*` or `cmd.*`.
The default measurement output (the delta-compressed payload from
`outputUtils.formatMsg`) keeps `msg.topic = config.general.name` per
the existing convention. `evt.*` is for *additional* event-shaped
emissions, not for the per-tick measurement stream.
#### Aliases for legacy names
Each `commands/index.js` declares the canonical name as `topic` and
lists pre-refactor names in `aliases`. The first time an alias fires,
the runtime logs a one-time deprecation warning. Aliases are removed
in Phase 7 after one release cycle.
#### Why these prefixes (the reasoning)
Today's topics mix `setMode` (verb-noun, no separator), `q_in`
`declareChildGetter(name, softwareType, category?)` (provided by
BaseDomain) installs a getter that flattens
`this.child[softwareType]` into one object keyed by child id (across
all categories) — or filters by `category` if given.
The registry is the source of truth; the getters keep call sites
readable. `Object.values(this.machines).forEach(...)` works exactly
like before; assignments like `this.machines[id] = child` no longer
work — registration goes through `this.router` (or `registerChild`).
### Two output strategies — domain decides
| Strategy | When to pick | What domain does | What adapter does |
|---|---|---|---|
| **Event-driven** (default) | Domain reacts to incoming events (measurements, state changes, commands) and has no genuinely time-driven math. | Fire `this.emitter.emit('output-changed')` whenever the public output state shifts. | Subscribes to `'output-changed'`; on each fire, calls `getOutput()` and pushes the delta-compressed message. |
| **Tick-driven** (opt-in) | Domain has time-driven math that can't be expressed as a reaction to events (integrators, simulators, time-based thresholds). | Implement `tick()`. Fire `'output-changed'` from inside it whenever the tick changes output state. | Calls `tick()` every `static tickInterval` ms (set on the nodeClass subclass). Listens to `'output-changed'` the same as event-driven nodes. |
Both strategies funnel into the same `'output-changed'` → `getOutput()`
→ `formatMsg` → `node.send` pipeline. The only difference is what
fires the event.
### `this.context()`
Returns a frozen view passed to concern-modules so they don't reach into
`this`. Default shape:
```js
{
config: this.config,
logger: this.logger,
measurements: this.measurements,
emitter: this.emitter,
child: this.child,
unitPolicy: this.unitPolicy,
}
```
A node may override `context()` to add domain-specific keys (e.g.
`pumpingStation` adds `basin`).
### `getOutput()` and `getStatusBadge()` are the only required methods
Everything else is configuration. If a domain can be expressed without a
custom `tick()` (e.g. a passive aggregator), don't define one.
## 4. The commands registry
Each node has `src/commands/index.js` that exports an array of command
| `'any'` | Anything passes. Use when the handler accepts heterogeneous payloads. |
| `'none'` | **Trigger-only.** Handler is invoked regardless of payload. If `msg.payload` is anything other than `undefined`/`null`, the registry logs a `warn` (`"<topic>: payload ignored — this is a trigger-only topic"`) and still invokes the handler. Use for pure triggers (`cmd.calibrate`, `cmd.estop`, `set.simulator`, ...) — strict alternative to `'any'`. |
### Optional `description` field
A descriptor may include a free-text 1-line `description` string. It is surfaced by `.list()` (the docs surface) and consumed by `wikiGen`'s topic-contract auto-gen. Example:
- **Unit recognised but wrong measure** → log `warn` with the topic, the actual measure, the expected measure, and the accepted-unit list. Fall through with the supplied value assumed to already be in `default`.
- **Unit unrecognised** → log `warn` with the topic, the unknown unit, and the accepted-unit list. Fall through with the supplied value assumed to already be in `default`.
3. Rewrite the msg so the handler sees uniform inputs:
-`msg.payload` becomes the normalised number in `units.default` (the object form `{value, unit}` is flattened to a number).
-`msg.unit` is set to `units.default`.
Accepted-unit lists come from `convert.possibilities(measure)`. If that helper is unavailable, the warn falls back to `(see convert docs)`.
The `units` field is surfaced by `.list()` (so wikiGen + `query.units` can render the contract) and is `null` for descriptors that don't declare it.
| `fireAndWait(value)` | `Promise<result \| SUPERSEDED \| undefined>` | THIS specific fire's dispatch settles. If a later fire (plain or awaited) overwrites this one in the pending slot, the returned promise **resolves** with the frozen sentinel `LatestWinsGate.SUPERSEDED = { superseded: true }`. If the dispatch itself throws, the promise still resolves (with `undefined`) and the error is recorded on `gate.lastError` — callers don't need try/catch. |
The supersede-resolves-with-sentinel choice (rather than rejecting with
`'superseded'`) means consumers branch on a value:
```js
const r = await gate.fireAndWait(v);
if (r && r.superseded) return; // dropped by a later fire
// ... otherwise r is the dispatch's return value
```
`drain()` remains the right tool for "wait until idle" (returns one
promise regardless of how many fires landed); `fireAndWait` is per-fire.