@winkjs/composer 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/composer.js +1 -1
  4. package/src/core/batch/aoo-encoder.js +4 -4
  5. package/src/core/batch/sliding-window.js +2 -2
  6. package/src/core/codec/index.js +4 -4
  7. package/src/core/emitter-manager/mqtt/connection.js +209 -0
  8. package/src/core/emitter-manager/mqtt/constants.js +43 -29
  9. package/src/core/emitter-manager/mqtt/emitter.js +108 -633
  10. package/src/core/emitter-manager/mqtt/health.js +151 -0
  11. package/src/core/emitter-manager/mqtt/index.js +68 -4
  12. package/src/core/emitter-manager/mqtt/publish.js +292 -0
  13. package/src/core/emitter-manager/mqtt/resolve-config.js +312 -0
  14. package/src/core/emitter-manager/mqtt/shutdown-drain.js +247 -0
  15. package/src/core/emitter-manager/terminal/assert-columns.js +1 -1
  16. package/src/core/emitter-manager/terminal/index.js +20 -5
  17. package/src/core/env-vars.js +143 -16
  18. package/src/core/logger/index.js +174 -0
  19. package/src/core/mqtt-protocol.js +2 -2
  20. package/src/core/partition-manager/get-stats.js +31 -0
  21. package/src/core/partition-manager/index.js +1 -0
  22. package/src/core/partition-manager/init.js +15 -0
  23. package/src/core/partition-manager/update.js +143 -87
  24. package/src/core/partition-manager/validate-flow.js +1 -1
  25. package/src/core/semantics/loader.js +1 -1
  26. package/src/core/semantics/warnings.js +5 -3
  27. package/src/core/shutdown-manager/index.js +71 -21
  28. package/src/core/source-manager/csv/start.js +109 -35
  29. package/src/core/source-manager/mqtt/client.js +276 -83
  30. package/src/core/source-manager/mqtt/dedup.js +19 -14
  31. package/src/core/source-manager/mqtt/index.js +33 -6
  32. package/src/core/source-manager/mqtt/status.js +211 -46
  33. package/src/core/source-manager/record-shape.js +55 -0
  34. package/src/core/source-manager/test-harness/field-generator.js +1 -1
  35. package/src/core/source-manager/test-harness/start.js +52 -10
  36. package/src/core/source-manager/test-harness/validate.js +1 -1
  37. package/src/core/storage-manager/questdb/address-policy.js +264 -0
  38. package/src/core/storage-manager/questdb/assert-columns.js +2 -2
  39. package/src/core/storage-manager/questdb/delivery-gate.js +218 -0
  40. package/src/core/storage-manager/questdb/ensure-tables.js +1 -1
  41. package/src/core/storage-manager/questdb/flush-engine.js +852 -0
  42. package/src/core/storage-manager/questdb/flush-tracker.js +331 -0
  43. package/src/core/storage-manager/questdb/index.js +592 -827
  44. package/src/core/storage-manager/questdb/persist-plan.js +147 -162
  45. package/src/core/storage-manager/questdb/resolve-options.js +403 -0
  46. package/src/core/storage-manager/questdb/shutdown-drain.js +181 -0
  47. package/src/core/storage-manager/questdb/skip-warnings.js +180 -0
  48. package/src/core/test-utils/tcp-proxy.js +194 -1
  49. package/src/core/tunable/helpers.js +1 -1
  50. package/src/core/utils/address/index.js +362 -0
  51. package/src/core/utils/address/probe.js +210 -0
  52. package/src/core/utils/callback-guard/index.js +176 -0
  53. package/src/core/utils/clock/index.js +34 -0
  54. package/src/core/utils/half-life/index.js +4 -4
  55. package/src/core/utils/jitter/index.js +51 -0
  56. package/src/core/utils/line-rate/index.js +104 -0
  57. package/src/core/utils/quantize/index.js +43 -19
  58. package/src/core/utils/validate/composer-validators.js +2 -2
  59. package/src/core/utils/validate/index.js +1 -1
  60. package/src/core/utils/validate/validate.js +1 -1
  61. package/src/core/wiring/assert-handle.js +5 -5
  62. package/src/core/wiring/assert-module.js +2 -2
  63. package/src/core/wiring/wire-emitters.js +7 -4
  64. package/src/core/wiring/wire-linear-graph.js +1 -1
  65. package/src/core/wiring/wire-node.js +6 -6
  66. package/src/core/wiring/wire-semantics.js +1 -1
  67. package/src/core/wiring/wire-storages.js +14 -5
  68. package/src/flow/driver.js +31 -6
  69. package/src/flow/expand-single-input-array.js +4 -4
  70. package/src/flow/flow-api-schemas.js +4 -4
  71. package/src/flow/flow.js +62 -41
  72. package/src/flow/for-each.js +12 -11
  73. package/src/flow/get-target-array.js +2 -2
  74. package/src/flow/load-node-module.js +1 -1
  75. package/src/flow/make-node-method.js +4 -4
  76. package/src/flow/resolve-pick-by-field.js +1 -1
  77. package/src/flow/run.js +200 -40
  78. package/src/flow/serialize.js +1 -1
  79. package/src/flow/spec-builder.js +1 -1
  80. package/src/flow/validate.js +1 -1
  81. package/src/nodes/butterworth-filter/init.js +5 -4
  82. package/src/nodes/categorize/update.js +3 -1
  83. package/src/nodes/controller/update.js +2 -1
  84. package/src/nodes/dwell-time-tracker/update.js +2 -1
  85. package/src/nodes/emit-if/helpers.js +57 -11
  86. package/src/nodes/emit-if/init.js +1 -1
  87. package/src/nodes/emit-if/update.js +8 -11
  88. package/src/nodes/page-hinkley/update.js +2 -1
  89. package/src/nodes/pass-if/update.js +3 -1
  90. package/src/nodes/persist-if/helpers.js +60 -4
  91. package/src/nodes/persist-if/init.js +1 -1
  92. package/src/nodes/persist-if/update.js +8 -16
  93. package/src/nodes/persistence-check/update.js +2 -1
  94. package/src/nodes/sanitize/update.js +4 -2
  95. package/src/nodes/swing-watch/update.js +3 -2
  96. package/src/nodes/threshold/update.js +2 -1
  97. package/src/nodes/trend/compute-confidence.js +4 -4
  98. package/src/nodes/trend/update.js +2 -1
  99. package/src/nodes/winnow/update.js +3 -1
  100. package/src/tools/training/features.js +13 -13
  101. package/src/tools/training/logistic.js +1 -1
  102. package/src/tools/training/metrics.js +2 -2
  103. package/src/tools/training/scale.js +2 -2
  104. package/src/tools/training/validate.js +7 -7
  105. package/src/core/utils/console-formatter/index.js +0 -207
  106. package/src/nodes/archive/csv/index.js +0 -6
  107. package/src/nodes/archive/csv/init.js +0 -36
  108. package/src/nodes/archive/csv/load.js +0 -70
  109. package/src/nodes/archive/csv/reset.js +0 -14
  110. package/src/nodes/archive/csv/simple-parser.js +0 -216
  111. package/src/nodes/archive/csv/test.js +0 -173
  112. package/src/nodes/archive/csv/update.js +0 -63
  113. package/src/nodes/sse-emitter/index.js +0 -7
  114. package/src/nodes/sse-emitter/init.js +0 -105
  115. package/src/nodes/sse-emitter/introspect.js +0 -134
  116. package/src/nodes/sse-emitter/publish-to.js +0 -6
  117. package/src/nodes/sse-emitter/recompute.js +0 -16
  118. package/src/nodes/sse-emitter/reset.js +0 -19
  119. package/src/nodes/sse-emitter/update.js +0 -69
  120. package/src/nodes/sse-emitter/utils/broadcast-heartbeat.js +0 -36
  121. package/src/nodes/sse-emitter/utils/build-message.js +0 -31
  122. package/src/nodes/sse-emitter/utils/connection-manager.js +0 -45
  123. package/src/nodes/sse-emitter/utils/endpoint-initializer.js +0 -20
  124. package/src/nodes/sse-emitter/utils/health-monitor.js +0 -38
  125. package/src/nodes/sse-emitter/utils/qos-handler.js +0 -41
@@ -1,7 +1,7 @@
1
1
  // core/source-manager/mqtt/index.js
2
2
 
3
3
  /**
4
- * @fileoverview MQTT source adapter for WinkComposer aggregator.
4
+ * @fileoverview MQTT source adapter for winkComposer.
5
5
  *
6
6
  * Subscribes to edge composer MQTT outputs with deduplication for QoS 1.
7
7
  * Extracts winkDedupId from MQTT v5 user properties to filter retransmissions.
@@ -31,7 +31,9 @@
31
31
  * of `configSchema` — the wiring layer adds them to the config at start
32
32
  * time, after DSL validation. A user-supplied `onMessage` would be
33
33
  * silently overwritten; the unknown-key rejection turns that mistake
34
- * into a fail-fast error instead.
34
+ * into a fail-fast error instead. `onMessage` is also outside the
35
+ * callback guard on purpose: the flow's dispatch guard owns it, so a
36
+ * pipeline throw is contained once, at that one chokepoint (ADR-027).
35
37
  *
36
38
  * ASSUMPTIONS
37
39
  * -----------
@@ -49,6 +51,10 @@
49
51
  * 2. Duplicate filtering is opt-in per message: only messages that
50
52
  * carry `winkDedupId` are protected. Watch the `dedupBypassed`
51
53
  * counter to spot publishers that are not stamping ids.
54
+ * 3. No payload size cap and no intake rate cap. The broker's own
55
+ * per-client limits are the first line against a flood; TCP
56
+ * back-pressure bounds the rest. A cap is ADR-026 work
57
+ * (proposed), together with TLS and authentication.
52
58
  *
53
59
  * Usage in flow:
54
60
  * import mqtt from './core/source-manager/mqtt/index.js';
@@ -65,8 +71,24 @@
65
71
  */
66
72
 
67
73
  import { validators } from '../../utils/validate/index.js';
74
+ import { classifyAddress } from '../../utils/address/index.js';
68
75
  import { createMQTTSourceClient } from './client.js';
69
76
 
77
+ /**
78
+ * Schema validator for `brokerUrl`: non-empty and never `localhost`
79
+ * (ADR-030). A value the URL grammar cannot read passes; the MQTT
80
+ * library reports its own error for it.
81
+ *
82
+ * @param {*} value - The configured value
83
+ * @returns {boolean} Whether the value is allowed
84
+ */
85
+ const isAllowedBrokerUrl = function ( value ) {
86
+ if ( !validators.nonEmptyString( value ) ) {
87
+ return false;
88
+ }
89
+ return classifyAddress( value, 'url' ).kind !== 'localhost';
90
+ }; // isAllowedBrokerUrl()
91
+
70
92
  // ============================================================================
71
93
  // SOURCE ADAPTER
72
94
  // ============================================================================
@@ -83,7 +105,9 @@ const id = 'mqtt';
83
105
  * Crash-survival class per ADR-018. For a source the value describes
84
106
  * the INPUT it can recover after a disconnect: the persistent broker
85
107
  * session holds subscribed QoS-1 messages across the gap and replays
86
- * them on reconnect.
108
+ * them on reconnect. The class is earned only under a fixed
109
+ * `clientId` and a broker with persistence on. The auto-generated name
110
+ * changes on every start, so a restart under it claims no backlog.
87
111
  *
88
112
  * @type {string}
89
113
  */
@@ -97,7 +121,9 @@ const durabilityClass = 'broker-queue';
97
121
  * @returns {boolean}
98
122
  */
99
123
  const isTopicOrTopicArray = function ( value ) {
100
- if ( typeof value === 'string' && value.length > 0 ) return true;
124
+ if ( typeof value === 'string' && value.length > 0 ) {
125
+ return true;
126
+ }
101
127
  if ( Array.isArray( value ) && value.length > 0 ) {
102
128
  return value.every( ( t ) => typeof t === 'string' && t.length > 0 );
103
129
  }
@@ -163,8 +189,9 @@ const configSchema = {
163
189
  brokerUrl: {
164
190
  type: 'string',
165
191
  required: true,
166
- validator: validators.nonEmptyString,
167
- error: 'brokerUrl must be a non-empty string (e.g., mqtt://broker.local:1883)'
192
+ validator: isAllowedBrokerUrl,
193
+ error: 'brokerUrl must be a non-empty broker URL with a literal address or a name, never ' +
194
+ 'localhost; e.g., mqtt://127.0.0.1:1883'
168
195
  },
169
196
  topics: {
170
197
  required: true,
@@ -25,6 +25,29 @@
25
25
  * dedupHits, dedupMisses, dedupBypassed, dedupCacheSize}`, emitted
26
26
  * on every tick() (the client's 1 Hz cadence) and on every health
27
27
  * transition. Consumers diff snapshots; nothing is ever reset.
28
+ * `delivered` means handed to the flow: a message the pipeline
29
+ * later failed on still counts here. Failure visibility belongs
30
+ * to the flow's MESSAGE_HANDLER_FAILED reports (ADR-018), not to
31
+ * this counter.
32
+ * - The logger facade (ADR-028) — one line per health edge, and a
33
+ * bounded line per record fault. These lines print with or without
34
+ * an `onStatus` handler. Inside a flow the runtime's own status
35
+ * wrapper forwards only red payloads when the user gave no handler,
36
+ * so a yellow edge that reached only the payload channel used to
37
+ * vanish. The line is the guaranteed audience. The payload
38
+ * additionally reaches a listening handler.
39
+ *
40
+ * yellow edge → warn source degraded [CODE]: <detail>
41
+ * red edge → error source error [CODE]: <detail>
42
+ * back to green → warn source recovered [CODE]: cleared after N s
43
+ * forced stop → warn source stopped: <note>
44
+ *
45
+ * An edge is a change of status or code. A phase change inside the
46
+ * same status and code prints nothing, so a retry storm cannot fill
47
+ * the log. A clean stop prints nothing. The per-record lines
48
+ * (`decode failed [DECODE_ERROR]`, `transform failed
49
+ * [CALLBACK_FAILED]`) print the first two of an episode in full and
50
+ * then one summary a minute, the emitter's attempt-line shape.
28
51
  *
29
52
  * Health rules (evaluated on events and on tick):
30
53
  * - RED — a subscribe failure (connected but deaf — no retry
@@ -40,6 +63,12 @@
40
63
  * Precedence is the order above; the first rule that matches names
41
64
  * the code.
42
65
  *
66
+ * Clock: the default `nowFn` is the stopwatch clock (`performance`),
67
+ * which only counts up. The wall clock can step when NTP corrects a
68
+ * board after boot or after a long time without network. A forward
69
+ * step would fire the 30 s red early; a backward step would delay it
70
+ * (ADR-018, long-running stability). Tests inject their own clock.
71
+ *
43
72
  * Hot-path allocation profile (ADR-018 zero-alloc — allocation only where
44
73
  * unavoidable, each residual stated):
45
74
  * - Counter bumps, ring writes, and the health evaluation are plain
@@ -50,20 +79,18 @@
50
79
  * metrics snapshots (1 Hz). Snapshots are fresh objects because a
51
80
  * consumer may retain them; mutating a shared one under the
52
81
  * caller's feet is a footgun we refuse.
53
- * - `nowFn()` (Date.now) runs once per received message to feed the
54
- * quiet-period clock — the same transient boxing the dedup cache
55
- * already documents.
56
- *
57
- * Callers without an `onStatus` handler still see error-path
58
- * payloads via a classified `console.error` line (the CSV source's
59
- * fallback pattern); lifecycle payloads stay quiet.
82
+ * - `nowFn()` runs once per received message to feed the quiet-period
83
+ * clock. It returns a fractional number, so the value is transiently
84
+ * boxed — the same profile the dedup cache documents.
85
+ * - The facade lines are built only on an edge or a record fault,
86
+ * never on the success path.
60
87
  *
61
88
  * ASSUMPTIONS
62
89
  * -----------
63
90
  * 1. All reporter calls come from one event loop (mqtt.js event
64
91
  * handlers plus one interval) — there is no locking.
65
- * 2. The clock (`nowFn`) moves forward; the time rules use plain
66
- * subtraction.
92
+ * 2. An injected `nowFn` counts up, in milliseconds. The time rules
93
+ * use plain subtraction.
67
94
  *
68
95
  * LIMITATIONS
69
96
  * -----------
@@ -75,15 +102,29 @@
75
102
  * 2. Counters are plain JS numbers: exact to 2^53. At one million
76
103
  * messages per second that is ~285 years of uptime — not the
77
104
  * binding constraint.
105
+ * 3. The per-record line bound paces on the stopwatch clock too
106
+ * (the shared line-rate helper), so a wall-clock step moves
107
+ * neither a summary nor what the health rules decide.
78
108
  *
79
109
  * @see src/core/source-manager/mqtt/client.js - The transport wiring
80
110
  * @see ADR-018 - lifecycle phases, status shapes, error codes
111
+ * @see ADR-028 - the line grammar
81
112
  */
82
113
 
83
114
  import {
84
115
  DISCONNECT_RED_MS,
85
116
  DECODE_RING_SIZE
86
117
  } from './constants.js';
118
+ import { wrapCallback } from '../../utils/callback-guard/index.js';
119
+ import { createLineBound } from '../../utils/line-rate/index.js';
120
+ import { monotonicNow } from '../../utils/clock/index.js';
121
+ import { logger } from '../../logger/index.js';
122
+
123
+ /** How many per-record fault lines per episode print in full. */
124
+ const FULL_RECORD_LINES_PER_EPISODE = 2;
125
+
126
+ /** The per-record summary interval, also the quiet gap that ends an episode. */
127
+ const RECORD_SUMMARY_INTERVAL_MS = 60000;
87
128
 
88
129
  // ============================================================================
89
130
  // VALIDATION HELPERS
@@ -98,12 +139,37 @@ import {
98
139
  */
99
140
  const assertOptionalFunction = function ( value, name ) {
100
141
  if ( value !== undefined && value !== null && typeof value !== 'function' ) {
101
- const err = new Error( `WinkComposer/mqtt-source: ${name} must be a function` );
142
+ const err = new Error( `winkComposer/mqttSource: ${name} must be a function` );
102
143
  err.code = 'INVALID_CONFIG';
103
144
  throw err;
104
145
  }
105
146
  };
106
147
 
148
+ // ============================================================================
149
+ // FACADE LINES
150
+ // ============================================================================
151
+
152
+ /**
153
+ * Builds the bounded facade line for one per-record fault family. The
154
+ * first FULL_RECORD_LINES_PER_EPISODE faults of an episode print in
155
+ * full; later ones are counted into one summary a minute.
156
+ *
157
+ * @param {string} label - The line's message, e.g. `decode failed [DECODE_ERROR]`
158
+ * @returns {Function} `( detail ) => void`
159
+ */
160
+ const createRecordLine = function ( label ) {
161
+ return createLineBound( {
162
+ fullLines: FULL_RECORD_LINES_PER_EPISODE,
163
+ intervalMs: RECORD_SUMMARY_INTERVAL_MS,
164
+ printFull: function ( detail ) {
165
+ logger.warn( `winkComposer/mqttSource: ${label}: ${detail}` );
166
+ },
167
+ printSummary: function ( count, seconds, detail ) {
168
+ logger.warn( `winkComposer/mqttSource: ${label}: ${detail}; ${count} more in the last ${seconds} s` );
169
+ }
170
+ } );
171
+ }; // createRecordLine()
172
+
107
173
  // ============================================================================
108
174
  // STATUS REPORTER FACTORY
109
175
  // ============================================================================
@@ -117,14 +183,15 @@ const assertOptionalFunction = function ( value, name ) {
117
183
  * @param {number} [options.expectedQuietPeriodMs] - Opt-in quiet rule:
118
184
  * yellow when no packet arrives for longer than this
119
185
  * @param {function} [options.dedupSizeFn] - Live dedup-cache size read
120
- * @param {function} [options.nowFn=Date.now] - Clock source. Injection
121
- * point for deterministic tests; production uses the default
186
+ * @param {function} [options.nowFn=monotonicNow] - Clock source, in
187
+ * milliseconds and counting up. Injection point for deterministic
188
+ * tests; production uses the stopwatch clock
122
189
  * @returns {Object} Reporter with lifecycle notes, hot-path counters,
123
190
  * tick and snapshot
124
191
  */
125
192
  const createStatusReporter = function ( options = {} ) {
126
193
  if ( typeof options !== 'object' || options === null ) {
127
- const err = new Error( 'WinkComposer/mqtt-source: status reporter options must be an object' );
194
+ const err = new Error( 'winkComposer/mqttSource: status reporter options must be an object' );
128
195
  err.code = 'INVALID_CONFIG';
129
196
  throw err;
130
197
  }
@@ -134,14 +201,14 @@ const createStatusReporter = function ( options = {} ) {
134
201
  onMetrics = null,
135
202
  expectedQuietPeriodMs = null,
136
203
  dedupSizeFn = null,
137
- nowFn = Date.now
204
+ nowFn = monotonicNow
138
205
  } = options;
139
206
 
140
207
  assertOptionalFunction( onStatus, 'onStatus' );
141
208
  assertOptionalFunction( onMetrics, 'onMetrics' );
142
209
  assertOptionalFunction( dedupSizeFn, 'dedupSizeFn' );
143
210
  if ( typeof nowFn !== 'function' ) {
144
- const err = new Error( 'WinkComposer/mqtt-source: nowFn must be a function' );
211
+ const err = new Error( 'winkComposer/mqttSource: nowFn must be a function' );
145
212
  err.code = 'INVALID_CONFIG';
146
213
  throw err;
147
214
  }
@@ -149,7 +216,7 @@ const createStatusReporter = function ( options = {} ) {
149
216
  ( typeof expectedQuietPeriodMs !== 'number' ||
150
217
  expectedQuietPeriodMs < 1 ||
151
218
  !Number.isInteger( expectedQuietPeriodMs ) ) ) {
152
- const err = new Error( 'WinkComposer/mqtt-source: expectedQuietPeriodMs must be a positive integer' );
219
+ const err = new Error( 'winkComposer/mqttSource: expectedQuietPeriodMs must be a positive integer' );
153
220
  err.code = 'INVALID_CONFIG';
154
221
  throw err;
155
222
  }
@@ -186,6 +253,18 @@ const createStatusReporter = function ( options = {} ) {
186
253
  let lastPhase = null;
187
254
  let lastCode = null;
188
255
 
256
+ // ── Facade-line state (ADR-028) ──────────────────────────────────
257
+ // The edge lines key on status and code only, so a phase change
258
+ // inside one degraded episode prints nothing. `degradedSince` and
259
+ // `degradedPhase` remember the first non-green edge of the
260
+ // episode for the recovery line.
261
+ let printedStatus = 'green';
262
+ let printedCode = null;
263
+ let degradedSince = 0;
264
+ let degradedPhase = null;
265
+ const decodeLine = createRecordLine( 'decode failed [DECODE_ERROR]' );
266
+ const transformLine = createRecordLine( 'transform failed [CALLBACK_FAILED]' );
267
+
189
268
  /**
190
269
  * Record one decode outcome in the ring.
191
270
  *
@@ -220,28 +299,68 @@ const createStatusReporter = function ( options = {} ) {
220
299
  };
221
300
  };
222
301
 
223
- /**
224
- * Emit a metrics snapshot when the caller asked for one.
225
- */
226
- const emitMetrics = function () {
227
- if ( onMetrics ) {
228
- onMetrics( snapshot() );
302
+ // Both user callbacks are armed by the shared callback guard.
303
+ // They were validated raw above and are wrapped here, once
304
+ // (ADR-018: a misbehaving user callback never reaches transport
305
+ // code and never fails silently). A broken onStatus reports
306
+ // through the facade in this adapter's classified line family.
307
+ // It cannot report through itself. A broken onMetrics reports on
308
+ // both channels; the comment at its wrap below explains why.
309
+ const safeOnStatus = wrapCallback( onStatus, {
310
+ name: 'onStatus',
311
+ severity: 'red',
312
+ report: function ( severity, name, detail ) {
313
+ logger.error( `winkComposer/mqttSource: user callback ${name} failed [CALLBACK_FAILED]: ${detail}` );
229
314
  }
230
- };
315
+ } );
231
316
 
232
317
  /**
233
- * Route a status payload: to the caller's handler when supplied;
234
- * otherwise error-path payloads go to a classified console.error
235
- * (never silent — ADR-018's two-party rule) and lifecycle
236
- * payloads stay quiet.
318
+ * Hand a status payload to the caller's handler, when there is
319
+ * one. The facade lines are printed by the callers of this
320
+ * function, so a payload with no handler is not silent.
237
321
  *
238
322
  * @param {Object} payload - The structured status payload
239
323
  */
240
324
  const emitStatus = function ( payload ) {
241
- if ( onStatus ) {
242
- onStatus( payload );
243
- } else if ( payload.error ) {
244
- console.error( `MQTT source error [${payload.error.code}]: ${payload.error.message}` );
325
+ if ( safeOnStatus ) {
326
+ safeOnStatus( payload );
327
+ }
328
+ };
329
+
330
+ // The metrics fault report allocates on the failure path only.
331
+ // `connected` and `phase` read the reporter's live state at fault
332
+ // time, so the payload tells the truth about the moment it fired.
333
+ //
334
+ // The facade line comes first and always fires. It is the
335
+ // guaranteed audience. Inside a flow the runtime installs its own
336
+ // onStatus wrapper. That wrapper forwards only red payloads when
337
+ // the user gave no handler, so the yellow payload alone could
338
+ // vanish (fresh-eyes find, 2026-08-28). The payload then
339
+ // additionally reaches a listening handler, so a health view sees
340
+ // the degradation.
341
+ const safeOnMetrics = wrapCallback( onMetrics, {
342
+ name: 'onMetrics',
343
+ severity: 'yellow',
344
+ report: function ( severity, name, detail ) {
345
+ logger.error( `winkComposer/mqttSource: user callback ${name} failed [CALLBACK_FAILED]: ${detail}` );
346
+ emitStatus( {
347
+ status: severity,
348
+ connected,
349
+ phase,
350
+ error: {
351
+ code: 'CALLBACK_FAILED',
352
+ message: `user callback ${name} failed: ${detail}`
353
+ }
354
+ } );
355
+ }
356
+ } );
357
+
358
+ /**
359
+ * Emit a metrics snapshot when the caller asked for one.
360
+ */
361
+ const emitMetrics = function () {
362
+ if ( safeOnMetrics ) {
363
+ safeOnMetrics( snapshot() );
245
364
  }
246
365
  };
247
366
 
@@ -299,7 +418,7 @@ const createStatusReporter = function ( options = {} ) {
299
418
  return lastSubscribeError;
300
419
  }
301
420
  if ( code === 'CONNECTION_LOST' ) {
302
- return `not connected for ${now - disconnectedAt}ms (red threshold ${DISCONNECT_RED_MS}ms)`;
421
+ return `not connected for ${Math.round( now - disconnectedAt )}ms (red threshold ${DISCONNECT_RED_MS}ms)`;
303
422
  }
304
423
  if ( code === 'CONNECT_FAILED' ) {
305
424
  return lastConnectError;
@@ -308,7 +427,43 @@ const createStatusReporter = function ( options = {} ) {
308
427
  return `decode-error ratio above 1% over the last ${ringCount} messages`;
309
428
  }
310
429
  // QUIET_PERIOD_EXCEEDED — the only code left.
311
- return `no message received for ${now - lastReceivedAt}ms (expected quiet period ${expectedQuietPeriodMs}ms)`;
430
+ return `no message received for ${Math.round( now - lastReceivedAt )}ms (expected quiet period ${expectedQuietPeriodMs}ms)`;
431
+ };
432
+
433
+ /**
434
+ * Print one facade line per health edge (ADR-028). An edge is a
435
+ * change of status or code. A yellow edge prints at warn, a red
436
+ * edge at error. The return to green prints at warn with the
437
+ * seconds since the first non-green edge of the episode. A stop
438
+ * is not a recovery, so it prints nothing.
439
+ *
440
+ * @param {string} status - The status just computed
441
+ * @param {number} now - Current timestamp
442
+ */
443
+ const printEdge = function ( status, now ) {
444
+ if ( status === printedStatus && healthCode === printedCode ) {
445
+ return;
446
+ }
447
+ if ( status === 'green' ) {
448
+ if ( phase !== 'stopped' ) {
449
+ const seconds = Math.round( ( now - degradedSince ) / 1000 );
450
+ const what = printedCode === null ? `: ${degradedPhase} cleared` : ` [${printedCode}]: cleared`;
451
+ logger.warn( `winkComposer/mqttSource: source recovered${what} after ${seconds} s` );
452
+ }
453
+ } else {
454
+ if ( printedStatus === 'green' ) {
455
+ degradedSince = now;
456
+ degradedPhase = phase;
457
+ }
458
+ const what = healthCode === null ? `: ${phase}` : ` [${healthCode}]: ${healthMessage( healthCode, now )}`;
459
+ if ( status === 'red' ) {
460
+ logger.error( `winkComposer/mqttSource: source error${what}` );
461
+ } else {
462
+ logger.warn( `winkComposer/mqttSource: source degraded${what}` );
463
+ }
464
+ }
465
+ printedStatus = status;
466
+ printedCode = healthCode;
312
467
  };
313
468
 
314
469
  /**
@@ -326,11 +481,12 @@ const createStatusReporter = function ( options = {} ) {
326
481
  lastPhase = phase;
327
482
  lastCode = healthCode;
328
483
 
484
+ printEdge( status, now );
329
485
  const payload = {
330
486
  status,
331
487
  connected,
332
488
  phase,
333
- msSinceLastMsg: now - lastReceivedAt
489
+ msSinceLastMsg: Math.round( now - lastReceivedAt )
334
490
  };
335
491
  if ( healthCode !== null ) {
336
492
  payload.error = { code: healthCode, message: healthMessage( healthCode, now ) };
@@ -423,7 +579,8 @@ const createStatusReporter = function ( options = {} ) {
423
579
  /**
424
580
  * The stop's time budget ran out and the socket was forced
425
581
  * closed. Reported with the sources' shared `note` convention
426
- * (CSV and testHarness use the same wording).
582
+ * (CSV and testHarness use the same wording), and as one warn
583
+ * line.
427
584
  *
428
585
  * @param {number} timeoutMs - The exceeded budget
429
586
  */
@@ -431,15 +588,19 @@ const createStatusReporter = function ( options = {} ) {
431
588
  phase = 'stopped';
432
589
  connected = false;
433
590
  const now = nowFn();
591
+ const note = `Stop took longer than ${timeoutMs}ms — forced.`;
434
592
  lastStatus = 'yellow';
435
593
  lastPhase = 'stopped';
436
594
  lastCode = null;
595
+ printedStatus = 'yellow';
596
+ printedCode = null;
597
+ logger.warn( `winkComposer/mqttSource: source stopped: ${note}` );
437
598
  emitStatus( {
438
599
  status: 'yellow',
439
600
  connected: false,
440
601
  phase: 'stopped',
441
- msSinceLastMsg: now - lastReceivedAt,
442
- note: `Stop took longer than ${timeoutMs}ms — forced.`
602
+ msSinceLastMsg: Math.round( now - lastReceivedAt ),
603
+ note
443
604
  } );
444
605
  emitMetrics();
445
606
  },
@@ -474,9 +635,10 @@ const createStatusReporter = function ( options = {} ) {
474
635
  },
475
636
 
476
637
  /**
477
- * A payload could not be decoded. Emits the mandated
478
- * per-record report (ADR-018: skip, classify, continue — never
479
- * silent), then re-evaluates the ratio rule.
638
+ * A payload could not be decoded. Prints the bounded facade
639
+ * line, emits the mandated per-record report (ADR-018: skip,
640
+ * classify, continue — never silent), then re-evaluates the
641
+ * ratio rule.
480
642
  *
481
643
  * @param {string} detail - Operator-facing description
482
644
  */
@@ -485,6 +647,7 @@ const createStatusReporter = function ( options = {} ) {
485
647
  skipped += 1;
486
648
  lastReceivedAt = nowFn();
487
649
  ringPush( 1 );
650
+ decodeLine( detail );
488
651
  emitStatus( {
489
652
  status: 'yellow',
490
653
  connected,
@@ -502,17 +665,19 @@ const createStatusReporter = function ( options = {} ) {
502
665
  },
503
666
 
504
667
  /**
505
- * The user's transform threw. Emits the mandated per-record
506
- * report (skip, classify, continue — the transform contract,
507
- * source-transform-uniformity 2026-07-11), classified as
508
- * CALLBACK_FAILED: user code, never a transport failure. No
509
- * evaluate() — a transform throw feeds no health rule, so
510
- * there is no transition to re-derive.
668
+ * The user's transform threw. Prints the bounded facade line
669
+ * and emits the mandated per-record report (skip, classify,
670
+ * continue — the transform contract, source-transform-
671
+ * uniformity 2026-07-11), classified as CALLBACK_FAILED: user
672
+ * code, never a transport failure. No evaluate() — a transform
673
+ * throw feeds no health rule, so there is no transition to
674
+ * re-derive.
511
675
  *
512
676
  * @param {string} detail - Operator-facing description
513
677
  */
514
678
  transformFailed: function ( detail ) {
515
679
  skipped += 1;
680
+ transformLine( detail );
516
681
  emitStatus( {
517
682
  status: 'yellow',
518
683
  connected,
@@ -0,0 +1,55 @@
1
+ // core/source-manager/record-shape.js
2
+
3
+ /**
4
+ * @fileoverview One definition of "usable record" for every source.
5
+ *
6
+ * A source hands the pipeline record objects. A decoded payload or a
7
+ * transform return can instead be a scalar, null, undefined, or a
8
+ * bare array. All of those are valid JSON documents, so decoding
9
+ * alone cannot reject them. Attaching metadata to them throws in
10
+ * strict mode. This module gives every source the same two tools:
11
+ * the guard predicate, and the plain name of the rejected shape for
12
+ * the per-record report (skip-classify-continue, ADR-018).
13
+ *
14
+ * Kept as one shared module so the MQTT and CSV sources cannot
15
+ * drift apart on what a usable record is (the cross-source
16
+ * transform contract).
17
+ */
18
+
19
+ /**
20
+ * Tell whether a decoded value can carry pipeline fields: a real
21
+ * object, not null, not an array. Hot-path cost is one comparison,
22
+ * one typeof, and one Array.isArray — no allocation.
23
+ *
24
+ * @param {*} value - Decoded payload or transform return
25
+ * @returns {boolean} True when the value is a usable record
26
+ */
27
+ const isUsableRecord = function ( value ) {
28
+ return ( value !== null ) && ( typeof value === 'object' ) && !Array.isArray( value );
29
+ };
30
+
31
+ /**
32
+ * Name a rejected value's shape for an operator-facing report.
33
+ * Called on the failure path only, so the string cost is fine.
34
+ *
35
+ * @param {*} value - The rejected value
36
+ * @returns {string} Plain shape name, e.g. 'a number', 'an array'
37
+ */
38
+ const describeShape = function ( value ) {
39
+ if ( value === null ) {
40
+ return 'null';
41
+ }
42
+ if ( value === undefined ) {
43
+ return 'undefined';
44
+ }
45
+ if ( Array.isArray( value ) ) {
46
+ return 'an array';
47
+ }
48
+ return `a ${typeof value}`;
49
+ };
50
+
51
+ // ============================================================================
52
+ // EXPORTS
53
+ // ============================================================================
54
+
55
+ export { isUsableRecord, describeShape };
@@ -80,6 +80,6 @@ export const generateField = function ( fieldSpec, prng, msgIndex, intervalMs )
80
80
  default:
81
81
  // Unreachable in practice — validate.js rejects unknown
82
82
  // types at startup. Kept for clarity at the boundary.
83
- throw new Error( `testHarness: unknown field type "${fieldSpec.type}"` );
83
+ throw new Error( `winkComposer/testHarness: unknown field type "${fieldSpec.type}"` );
84
84
  }
85
85
  };