@winkjs/composer 0.5.0 → 0.6.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 (104) hide show
  1. package/package.json +8 -8
  2. package/src/core/batch/aoo-encoder.js +4 -4
  3. package/src/core/batch/sliding-window.js +2 -2
  4. package/src/core/codec/index.js +4 -4
  5. package/src/core/emitter-manager/mqtt/emitter.js +44 -14
  6. package/src/core/emitter-manager/mqtt/index.js +2 -1
  7. package/src/core/emitter-manager/terminal/assert-columns.js +1 -1
  8. package/src/core/emitter-manager/terminal/index.js +4 -4
  9. package/src/core/env-vars.js +44 -3
  10. package/src/core/logger/index.js +174 -0
  11. package/src/core/mqtt-protocol.js +2 -2
  12. package/src/core/partition-manager/get-stats.js +31 -0
  13. package/src/core/partition-manager/index.js +1 -0
  14. package/src/core/partition-manager/init.js +15 -0
  15. package/src/core/partition-manager/update.js +143 -87
  16. package/src/core/partition-manager/validate-flow.js +1 -1
  17. package/src/core/semantics/loader.js +1 -1
  18. package/src/core/semantics/warnings.js +5 -3
  19. package/src/core/shutdown-manager/index.js +71 -21
  20. package/src/core/source-manager/csv/start.js +109 -35
  21. package/src/core/source-manager/mqtt/client.js +95 -36
  22. package/src/core/source-manager/mqtt/dedup.js +3 -3
  23. package/src/core/source-manager/mqtt/index.js +1 -1
  24. package/src/core/source-manager/mqtt/status.js +67 -14
  25. package/src/core/source-manager/record-shape.js +55 -0
  26. package/src/core/source-manager/test-harness/field-generator.js +1 -1
  27. package/src/core/source-manager/test-harness/start.js +52 -10
  28. package/src/core/source-manager/test-harness/validate.js +1 -1
  29. package/src/core/storage-manager/questdb/assert-columns.js +2 -2
  30. package/src/core/storage-manager/questdb/ensure-tables.js +1 -1
  31. package/src/core/storage-manager/questdb/index.js +55 -17
  32. package/src/core/storage-manager/questdb/persist-plan.js +32 -8
  33. package/src/core/tunable/helpers.js +1 -1
  34. package/src/core/utils/callback-guard/index.js +155 -0
  35. package/src/core/utils/half-life/index.js +4 -4
  36. package/src/core/utils/validate/composer-validators.js +2 -2
  37. package/src/core/utils/validate/index.js +1 -1
  38. package/src/core/utils/validate/validate.js +1 -1
  39. package/src/core/wiring/assert-handle.js +5 -5
  40. package/src/core/wiring/assert-module.js +2 -2
  41. package/src/core/wiring/wire-emitters.js +4 -3
  42. package/src/core/wiring/wire-linear-graph.js +1 -1
  43. package/src/core/wiring/wire-node.js +6 -6
  44. package/src/core/wiring/wire-semantics.js +1 -1
  45. package/src/core/wiring/wire-storages.js +4 -3
  46. package/src/flow/driver.js +31 -6
  47. package/src/flow/expand-single-input-array.js +4 -4
  48. package/src/flow/flow-api-schemas.js +4 -4
  49. package/src/flow/flow.js +41 -41
  50. package/src/flow/for-each.js +12 -11
  51. package/src/flow/get-target-array.js +2 -2
  52. package/src/flow/load-node-module.js +1 -1
  53. package/src/flow/make-node-method.js +4 -4
  54. package/src/flow/resolve-pick-by-field.js +1 -1
  55. package/src/flow/run.js +176 -38
  56. package/src/flow/serialize.js +1 -1
  57. package/src/flow/spec-builder.js +1 -1
  58. package/src/flow/validate.js +1 -1
  59. package/src/nodes/butterworth-filter/init.js +5 -4
  60. package/src/nodes/categorize/update.js +3 -1
  61. package/src/nodes/controller/update.js +2 -1
  62. package/src/nodes/dwell-time-tracker/update.js +2 -1
  63. package/src/nodes/emit-if/helpers.js +57 -11
  64. package/src/nodes/emit-if/init.js +1 -1
  65. package/src/nodes/emit-if/update.js +8 -11
  66. package/src/nodes/page-hinkley/update.js +2 -1
  67. package/src/nodes/pass-if/update.js +3 -1
  68. package/src/nodes/persist-if/helpers.js +60 -4
  69. package/src/nodes/persist-if/init.js +1 -1
  70. package/src/nodes/persist-if/update.js +8 -16
  71. package/src/nodes/persistence-check/update.js +2 -1
  72. package/src/nodes/sanitize/update.js +4 -2
  73. package/src/nodes/swing-watch/update.js +3 -2
  74. package/src/nodes/threshold/update.js +2 -1
  75. package/src/nodes/trend/compute-confidence.js +4 -4
  76. package/src/nodes/trend/update.js +2 -1
  77. package/src/nodes/winnow/update.js +3 -1
  78. package/src/tools/training/features.js +13 -13
  79. package/src/tools/training/logistic.js +1 -1
  80. package/src/tools/training/metrics.js +2 -2
  81. package/src/tools/training/scale.js +2 -2
  82. package/src/tools/training/validate.js +7 -7
  83. package/src/core/emitter-manager/mqtt/mqtt-store.js +0 -538
  84. package/src/core/utils/console-formatter/index.js +0 -207
  85. package/src/nodes/archive/csv/index.js +0 -6
  86. package/src/nodes/archive/csv/init.js +0 -36
  87. package/src/nodes/archive/csv/load.js +0 -70
  88. package/src/nodes/archive/csv/reset.js +0 -14
  89. package/src/nodes/archive/csv/simple-parser.js +0 -216
  90. package/src/nodes/archive/csv/test.js +0 -173
  91. package/src/nodes/archive/csv/update.js +0 -63
  92. package/src/nodes/sse-emitter/index.js +0 -7
  93. package/src/nodes/sse-emitter/init.js +0 -105
  94. package/src/nodes/sse-emitter/introspect.js +0 -134
  95. package/src/nodes/sse-emitter/publish-to.js +0 -6
  96. package/src/nodes/sse-emitter/recompute.js +0 -16
  97. package/src/nodes/sse-emitter/reset.js +0 -19
  98. package/src/nodes/sse-emitter/update.js +0 -69
  99. package/src/nodes/sse-emitter/utils/broadcast-heartbeat.js +0 -36
  100. package/src/nodes/sse-emitter/utils/build-message.js +0 -31
  101. package/src/nodes/sse-emitter/utils/connection-manager.js +0 -45
  102. package/src/nodes/sse-emitter/utils/endpoint-initializer.js +0 -20
  103. package/src/nodes/sse-emitter/utils/health-monitor.js +0 -38
  104. package/src/nodes/sse-emitter/utils/qos-handler.js +0 -41
@@ -25,6 +25,11 @@
25
25
  * malformed, transform-dropped, or
26
26
  * transform-failed. For in-range rows,
27
27
  * count + skipped covers every data row read.
28
+ * `count` means handed to the flow: a row the
29
+ * pipeline later failed on still counts here.
30
+ * Failure visibility belongs to the flow's
31
+ * MESSAGE_HANDLER_FAILED reports (ADR-018),
32
+ * not to this counter.
28
33
  * `phase: 'errored'` — terminal red: the run loop failed and the
29
34
  * stream is dead (the ADR-018 two-tier rule).
30
35
  * `phase: 'stopped'` — forced stop exceeded its time budget
@@ -40,19 +45,45 @@
40
45
  * stream continues — per-record skip-classify-continue (ADR-018).
41
46
  * A bad field VALUE in a parseable row is NOT a decode error;
42
47
  * it passes through for the pipeline (`sanitize`) to judge.
43
- * - `CALLBACK_FAILED` — the user's `transform` threw on one row. The
44
- * row is skipped (counted in `skipped`), the throw is reported per
45
- * record with `status: 'yellow'`, and the stream continues — user
46
- * code is never reported as a stream failure. Fix the transform
47
- * function; the report names the data row. Uniform with the MQTT
48
- * source (transform contract, 2026-07-11).
48
+ * - `CALLBACK_FAILED` — a piece of user code failed. It covers two
49
+ * cases. Case one: the user's `transform` threw on one row, or
50
+ * returned a scalar or array where a record object was needed. The
51
+ * row is skipped (counted in `skipped`), the fault is reported per
52
+ * record with `status: 'yellow'`, and the stream continues. User
53
+ * code is never reported as a stream failure. A null/undefined
54
+ * return is NOT this case: it is the documented intentional drop.
55
+ * Fix the transform function; the report names the data row.
56
+ * Uniform with the MQTT source (transform contract, 2026-07-11).
57
+ * Case two: the user's `onStatus` itself threw or rejected. The
58
+ * shared callback guard contains that fault (ADR-018). The replay
59
+ * continues, and each fault becomes one classified console line
60
+ * that carries the detail.
49
61
  * - `READ_ERROR` — stream read failed mid-stream (encoding error,
50
62
  * truncation, decoder failure, etc.). Catch-all for non-open failures.
63
+ * Narrowed by the flow's dispatch guard (ADR-018): in a flow, a
64
+ * pipeline fault is contained at the flow's own chokepoint and
65
+ * reported as MESSAGE_HANDLER_FAILED, so it never surfaces here and
66
+ * never ends the replay. The code remains live for genuine stream
67
+ * failures, and for direct callers of start() whose own onMessage
68
+ * throws.
51
69
  */
52
70
 
53
71
  import fs from 'node:fs';
54
72
  import readline from 'node:readline';
55
73
 
74
+ import { isUsableRecord, describeShape } from '../record-shape.js';
75
+ import { wrapCallback, wrapTransform, TRANSFORM_THREW } from '../../utils/callback-guard/index.js';
76
+ import { logger } from '../../logger/index.js';
77
+
78
+ /**
79
+ * Console channel for the callback guard: one classified line in this
80
+ * source's family. Receives an already-safe detail string, never the
81
+ * raw thrown value.
82
+ */
83
+ const reportCallbackFault = function ( severity, name, detail ) {
84
+ logger.error( `winkComposer/csvSource: user callback ${name} failed [CALLBACK_FAILED]: ${detail}` );
85
+ }; // reportCallbackFault()
86
+
56
87
  /**
57
88
  * Detects delimiter from header line.
58
89
  * @param {string} headerLine - First line of CSV
@@ -187,7 +218,8 @@ const checkRange = function ( rangeKey, wasInRange, startMsgId, endMsgId ) {
187
218
  * @param {boolean} [config.dynamicTyping=true] - Auto-cast values
188
219
  * @param {Function} [config.transform] - Optional row transform:
189
220
  * ( row ) => row; return null/undefined to drop (counted in skipped);
190
- * a throw skips the row (CALLBACK_FAILED) and the stream continues
221
+ * a throw or a scalar/array return skips the row (CALLBACK_FAILED)
222
+ * and the stream continues
191
223
  * @param {Function} [config.onStatus] - Status callback; completion arrives
192
224
  * here as `{phase: 'complete', count, skipped}` (per ADR-018 there is
193
225
  * no onComplete callback)
@@ -213,12 +245,30 @@ export const start = function ( config ) {
213
245
  endMsgId = null
214
246
  } = config;
215
247
 
248
+ // The guard below turns a non-function into null (absent). So a
249
+ // misconfigured handler must be rejected here, loudly, before the
250
+ // wrap can silently erase it. Null stays legal as "no handler".
251
+ if ( onStatus !== null && typeof onStatus !== 'function' ) {
252
+ const err = new Error( 'winkComposer/csvSource: onStatus must be a function' );
253
+ err.code = 'INVALID_CONFIG';
254
+ throw err;
255
+ }
256
+
216
257
  let stopped = false;
217
258
  let rowCount = 0;
218
259
  let rowIndex = 0;
219
260
  let skippedCount = 0;
220
261
  let inRange = ( startMsgId === null ); // Start immediately if no startMsgId
221
262
 
263
+ // The user's onStatus is armed once by the shared callback guard
264
+ // (ADR-018). A throw or rejection inside it becomes one classified
265
+ // CALLBACK_FAILED console line, and the replay continues. Absent
266
+ // stays null, so every no-handler console fallback below keeps
267
+ // its exact meaning.
268
+ const safeOnStatus = wrapCallback( onStatus, {
269
+ name: 'onStatus', severity: 'red', report: reportCallbackFault
270
+ } );
271
+
222
272
  // Per-record skip-classify-continue (ADR-018): a structurally
223
273
  // malformed row is skipped and signalled — never a silent drop. When
224
274
  // the caller owns reporting (onStatus supplied) the framework stays
@@ -226,50 +276,73 @@ export const start = function ( config ) {
226
276
  // visible (ADR-018's two-party rule).
227
277
  const reportDecodeError = function ( dataRowIndex, fault ) {
228
278
  const message = `data row ${dataRowIndex}: ${fault} — row skipped`;
229
- if ( onStatus ) {
230
- onStatus( {
279
+ if ( safeOnStatus ) {
280
+ safeOnStatus( {
231
281
  status: 'yellow',
232
282
  connected: true,
233
283
  phase: 'running',
234
284
  error: { code: 'DECODE_ERROR', message }
235
285
  } );
236
286
  } else {
237
- console.error( `CSV source error [DECODE_ERROR]: ${message}` );
287
+ logger.error( `winkComposer/csvSource: decode failed [DECODE_ERROR]: ${message}` );
238
288
  }
239
289
  };
240
290
 
241
- // A throwing transform is user code, never a stream failure: the
291
+ // A faulty transform is user code, never a stream failure: the
242
292
  // row is skipped with a per-record CALLBACK_FAILED report and the
243
293
  // stream continues (transform contract, uniform with the MQTT
244
294
  // source, 2026-07-11). Same two-party reporting rule as decode
245
- // errors above.
246
- const reportCallbackError = function ( dataRowIndex, err ) {
247
- const message = `data row ${dataRowIndex}: transform threw: ${err.message} — row skipped`;
248
- if ( onStatus ) {
249
- onStatus( {
295
+ // errors above. `detail` names the fault (a throw, or an
296
+ // unusable return shape).
297
+ const reportCallbackError = function ( dataRowIndex, detail ) {
298
+ const message = `data row ${dataRowIndex}: ${detail} — row skipped`;
299
+ if ( safeOnStatus ) {
300
+ safeOnStatus( {
250
301
  status: 'yellow',
251
302
  connected: true,
252
303
  phase: 'running',
253
304
  error: { code: 'CALLBACK_FAILED', message }
254
305
  } );
255
306
  } else {
256
- console.error( `CSV source error [CALLBACK_FAILED]: ${message}` );
307
+ logger.error( `winkComposer/csvSource: source error [CALLBACK_FAILED]: ${message}` );
257
308
  }
258
309
  };
259
310
 
311
+ // The user's transform is pre-armed by the shared callback guard,
312
+ // once here — never per row (ADR-018). A throw inside it becomes
313
+ // the sentinel plus one per-record CALLBACK_FAILED report. The
314
+ // row index travels as the guard's per-call context, so the
315
+ // success path allocates nothing.
316
+ const reportTransformFault = function ( detail, dataRowIndex ) {
317
+ reportCallbackError( dataRowIndex, `transform threw: ${detail}` );
318
+ };
319
+ const guardedTransform = transform ? wrapTransform( transform, reportTransformFault ) : null;
320
+
260
321
  // Run the user's transform under guard. Returns the transformed
261
- // row; null when the transform threw (reported above) — the caller
262
- // counts every null/undefined result as skipped.
322
+ // row; null when the row must be skipped — the caller counts
323
+ // every null/undefined result as skipped. Three skip faces: a
324
+ // throw (reported), a scalar or array return (reported — an
325
+ // unusable record), and a null/undefined return (the documented
326
+ // intentional drop, counted but never reported).
263
327
  const applyTransform = function ( row, dataRowIndex ) {
264
- if ( !transform ) {
328
+ if ( !guardedTransform ) {
265
329
  return row;
266
330
  }
267
- try {
268
- return transform( row );
269
- } catch ( err ) {
270
- reportCallbackError( dataRowIndex, err );
331
+ const out = guardedTransform( row, dataRowIndex );
332
+ // The sentinel check runs FIRST: the sentinel is a plain
333
+ // object, so a later isUsableRecord check would wave it
334
+ // through as a row.
335
+ if ( out === TRANSFORM_THREW ) {
336
+ return null;
337
+ }
338
+ if ( out === null || out === undefined ) {
339
+ return null;
340
+ }
341
+ if ( !isUsableRecord( out ) ) {
342
+ reportCallbackError( dataRowIndex, `transform returned ${describeShape( out )} — a record object (or null/undefined to drop) is required` );
271
343
  return null;
272
344
  }
345
+ return out;
273
346
  };
274
347
 
275
348
  // Holds the file stream so the stop function can close it if the
@@ -292,7 +365,7 @@ export const start = function ( config ) {
292
365
  let headers = null;
293
366
  let delimiter = ',';
294
367
 
295
- if ( onStatus ) onStatus( {
368
+ if ( safeOnStatus ) safeOnStatus( {
296
369
  status: 'green',
297
370
  connected: true,
298
371
  phase: 'starting',
@@ -308,7 +381,7 @@ export const start = function ( config ) {
308
381
  if ( !headers ) {
309
382
  delimiter = detectDelimiter( line );
310
383
  headers = parseLine( line, delimiter ).values;
311
- if ( onStatus ) onStatus( {
384
+ if ( safeOnStatus ) safeOnStatus( {
312
385
  status: 'green',
313
386
  connected: true,
314
387
  phase: 'headers',
@@ -340,9 +413,10 @@ export const start = function ( config ) {
340
413
  if ( inRange ) {
341
414
  const msg = applyTransform( row, rowIndex );
342
415
 
343
- // Only null/undefined mean drop — any other return,
344
- // however falsy, is delivered (transform contract,
345
- // uniform with the MQTT source, 2026-07-11).
416
+ // applyTransform maps every skip face (throw, bad
417
+ // shape, intentional drop) to null/undefined; the
418
+ // caller only counts (transform contract, uniform
419
+ // with the MQTT source, 2026-07-11).
346
420
  if ( msg !== null && msg !== undefined ) {
347
421
  await onMessage( msg );
348
422
  rowCount += 1;
@@ -365,7 +439,7 @@ export const start = function ( config ) {
365
439
 
366
440
  // Completion travels onStatus with the uniform `count` field
367
441
  // (per ADR-018 there is no onComplete callback).
368
- if ( onStatus ) onStatus( {
442
+ if ( safeOnStatus ) safeOnStatus( {
369
443
  status: 'green',
370
444
  connected: false,
371
445
  phase: 'complete',
@@ -391,17 +465,17 @@ export const start = function ( config ) {
391
465
  'SOURCE_UNREACHABLE' :
392
466
  'READ_ERROR';
393
467
  const message = ( err && err.message ) ? err.message : String( err );
394
- if ( onStatus ) {
468
+ if ( safeOnStatus ) {
395
469
  // Terminal red: the stream is dead. Uniform payload with
396
470
  // phase 'errored' per the ADR-018 two-tier rule.
397
- onStatus( {
471
+ safeOnStatus( {
398
472
  status: 'red',
399
473
  connected: false,
400
474
  phase: 'errored',
401
475
  error: { code, message }
402
476
  } );
403
477
  } else {
404
- console.error( `CSV source error [${code}]: ${message}` );
478
+ logger.error( `winkComposer/csvSource: source error [${code}]: ${message}` );
405
479
  }
406
480
  } ).finally( () => {
407
481
  resolveFinished();
@@ -438,8 +512,8 @@ export const start = function ( config ) {
438
512
  };
439
513
  forceTimer = setTimeout( () => {
440
514
  if ( activeStream ) activeStream.destroy();
441
- if ( onStatus ) {
442
- onStatus( {
515
+ if ( safeOnStatus ) {
516
+ safeOnStatus( {
443
517
  status: 'yellow',
444
518
  connected: false,
445
519
  phase: 'stopped',
@@ -15,7 +15,8 @@
15
15
  * Message flow:
16
16
  * MQTT broker → subscribe → extract userProperties → check dedup cache
17
17
  * → if duplicate: skip, count (dedupHits — not a status event)
18
- * → if new: decode payload → transform (optional) → onMessage(msg)
18
+ * → if new: decode payload → shape guard + metadata attach
19
+ * → transform (optional) → onMessage(msg)
19
20
  *
20
21
  * Status shape — the ADR-018 core, plus this adapter's additions:
21
22
  * `{status: 'green' | 'yellow' | 'red', connected, phase,
@@ -34,14 +35,27 @@
34
35
  * field. Thrown synchronously from the factory (ADR-018 fail-fast
35
36
  * setup), never emitted.
36
37
  * - `DECODE_ERROR` — runtime, yellow. Two faces: a per-record
37
- * report for every payload that cannot be decoded (skip, classify,
38
- * continue — ADR-018), and a health flip when the decode-error ratio over
39
- * the last 1,000 messages exceeds 1 %.
40
- * - `CALLBACK_FAILED` — runtime, yellow. The user's `transform`
41
- * threw; that one message is skipped (counted in `skipped`) and the
42
- * stream continues. Fix the transform function — the report names
43
- * the topic and the thrown message. Uniform with the CSV source
44
- * (transform contract, 2026-07-11).
38
+ * report for every payload that does not yield a usable record, and
39
+ * a health flip when the decode-error ratio over the last 1,000
40
+ * messages exceeds 1 %. The per-record face covers more than parse
41
+ * failures (a widening of ADR-018 §9's original wording): a payload
42
+ * that decodes to a scalar, null, or a bare array is rejected too,
43
+ * and so is a record the metadata attach cannot write to (a frozen
44
+ * record from a custom codec). Skip, classify, continue — the
45
+ * report names what arrived. A bare scalar landing here usually
46
+ * means a too-wide topic subscription; narrow the topic filter.
47
+ * - `CALLBACK_FAILED` — runtime, yellow. A piece of user code
48
+ * failed. It covers two cases. Case one: the user's `transform`
49
+ * threw, or returned a scalar or array where a record object was
50
+ * needed. That one message is skipped (counted in `skipped`) and
51
+ * the stream continues. Fix the transform function — the report
52
+ * names the topic and the fault. A null/undefined return is NOT
53
+ * this case: it is the documented intentional drop. Uniform with
54
+ * the CSV source (transform contract, 2026-07-11). Case two: the
55
+ * user's `onStatus` or `onMetrics` itself threw or rejected. The
56
+ * shared callback guard contains that fault (ADR-018, wired in
57
+ * status.js). The stream continues, and each fault is reported
58
+ * once with the detail.
45
59
  * - `SUBSCRIBE_FAILED` — runtime, red. The broker refused the
46
60
  * subscription (typically ACL). Red immediately: nothing retries a
47
61
  * subscribe until the next reconnect, so a deaf-but-connected source
@@ -119,6 +133,9 @@ import {
119
133
  } from './constants.js';
120
134
  import { createDedupCache } from './dedup.js';
121
135
  import { createStatusReporter } from './status.js';
136
+ import { isUsableRecord, describeShape } from '../record-shape.js';
137
+ import { wrapTransform, TRANSFORM_THREW } from '../../utils/callback-guard/index.js';
138
+ import { logger } from '../../logger/index.js';
122
139
 
123
140
  // ============================================================================
124
141
  // CLIENT FACTORY
@@ -133,7 +150,10 @@ import { createStatusReporter } from './status.js';
133
150
  * @param {string|string[]} config.topics - Topic(s) to subscribe to (supports wildcards)
134
151
  * @param {function} config.onMessage - Message handler: (message) => void
135
152
  * @param {Object} [config.codec] - Codec for payload decoding (default: JSON.parse)
136
- * @param {function} [config.transform] - Optional message transform: (msg) => transformedMsg
153
+ * @param {function} [config.transform] - Optional message transform:
154
+ * (msg) => transformedMsg; return null/undefined to drop (counted in
155
+ * skipped); a throw or a scalar/array return skips the message
156
+ * (CALLBACK_FAILED) and the stream continues
137
157
  * @param {number} [config.dedupWindowMs=120000] - Dedup time bound (ADR-022)
138
158
  * @param {number} [config.dedupMaxEntries=65536] - Dedup count cap (ADR-022)
139
159
  * @param {string} [config.clientId] - MQTT client ID (auto-generated if omitted)
@@ -168,17 +188,17 @@ const createMQTTSourceClient = function ( config ) {
168
188
  // Validate required config. Per ADR-018, setup-time throws carry
169
189
  // classified err.code (INVALID_CONFIG for missing/malformed config fields).
170
190
  if ( !brokerUrl ) {
171
- const err = new Error( 'WinkComposer/mqtt-source: brokerUrl is required' );
191
+ const err = new Error( 'winkComposer/mqttSource: brokerUrl is required' );
172
192
  err.code = 'INVALID_CONFIG';
173
193
  throw err;
174
194
  }
175
195
  if ( !topics || ( Array.isArray( topics ) && topics.length === 0 ) ) {
176
- const err = new Error( 'WinkComposer/mqtt-source: topics is required' );
196
+ const err = new Error( 'winkComposer/mqttSource: topics is required' );
177
197
  err.code = 'INVALID_CONFIG';
178
198
  throw err;
179
199
  }
180
200
  if ( typeof onMessage !== 'function' ) {
181
- const err = new Error( 'WinkComposer/mqtt-source: onMessage must be a function' );
201
+ const err = new Error( 'winkComposer/mqttSource: onMessage must be a function' );
182
202
  err.code = 'INVALID_CONFIG';
183
203
  throw err;
184
204
  }
@@ -201,6 +221,17 @@ const createMQTTSourceClient = function ( config ) {
201
221
 
202
222
  reporter.starting();
203
223
 
224
+ // The user's transform runs under the shared callback guard, armed
225
+ // once here — never per message. A throw inside it becomes the
226
+ // sentinel plus one per-record CALLBACK_FAILED report through the
227
+ // reporter; the message handler turns the sentinel into a skip.
228
+ // The topic travels as the guard's per-call context, so the
229
+ // success path allocates nothing (ADR-018).
230
+ const reportTransformFault = function ( detail, topic ) {
231
+ reporter.transformFailed( `topic '${topic}': transform threw: ${detail} — message skipped` );
232
+ };
233
+ const guardedTransform = transform ? wrapTransform( transform, reportTransformFault ) : null;
234
+
204
235
  // Generate client ID if not provided
205
236
  const generatedClientId = clientId || `wink-source-${Date.now()}`;
206
237
 
@@ -211,8 +242,8 @@ const createMQTTSourceClient = function ( config ) {
211
242
  // Warn once at startup; the fix is one config line. Setup path
212
243
  // only — nothing here touches the message path.
213
244
  if ( !clientId && cleanStart !== true ) {
214
- console.warn(
215
- 'WinkComposer/mqtt-source: no clientId configured — this session ' +
245
+ logger.warn(
246
+ 'winkComposer/mqttSource: no clientId configured — this session ' +
216
247
  `is persistent under the auto-generated name '${generatedClientId}'. ` +
217
248
  'After a restart, composer connects under a NEW name, so messages ' +
218
249
  'the broker saved during the downtime are never delivered. Set a ' +
@@ -281,9 +312,16 @@ const createMQTTSourceClient = function ( config ) {
281
312
  reporter.idAccepted();
282
313
  }
283
314
 
284
- // Decode payload. A payload that cannot be decoded is skipped,
315
+ // Decode payload, check its shape, and attach metadata — one
316
+ // guarded region. A failure anywhere in it is skipped,
285
317
  // classified, and reported per record (ADR-018) — the stream
286
- // continues.
318
+ // continues. The shape guard is needed because a valid JSON
319
+ // document can be a scalar or a bare array; the attach is
320
+ // inside the guard because a codec can return a frozen record
321
+ // (or one with a non-writable _topic), and the assignment then
322
+ // throws in strict mode. decodeOk() runs only when the whole
323
+ // record survived, so the ring gets exactly one entry per
324
+ // message.
287
325
  let message;
288
326
  try {
289
327
  if ( codec && typeof codec.unpack === 'function' ) {
@@ -291,35 +329,56 @@ const createMQTTSourceClient = function ( config ) {
291
329
  } else {
292
330
  message = JSON.parse( payload.toString() );
293
331
  }
332
+
333
+ if ( !isUsableRecord( message ) ) {
334
+ reporter.decodeFailed( `topic '${topic}': payload decoded to ${describeShape( message )} — a record object is required — message skipped` );
335
+ return;
336
+ }
337
+
338
+ // Attach metadata for downstream use
339
+ message._topic = topic; // eslint-disable-line no-underscore-dangle
340
+ message._dedupId = dedupId; // eslint-disable-line no-underscore-dangle
341
+
294
342
  reporter.decodeOk();
295
343
  } catch ( err ) {
296
344
  reporter.decodeFailed( `topic '${topic}': ${err.message} — message skipped` );
297
345
  return;
298
346
  }
299
347
 
300
- // Attach metadata for downstream use
301
- message._topic = topic; // eslint-disable-line no-underscore-dangle
302
- message._dedupId = dedupId; // eslint-disable-line no-underscore-dangle
303
-
304
- // Apply optional transform. Guarded: a throw skips this one
305
- // message with a per-record CALLBACK_FAILED report and the
306
- // stream continues — user code must never propagate into
307
- // mqtt.js's event processing (transform contract, 2026-07-11).
348
+ // Apply the optional transform, pre-armed by the shared guard
349
+ // at startup. A throw skips this one message with a per-record
350
+ // CALLBACK_FAILED report, and the stream continues. User code
351
+ // must never propagate into mqtt.js's event processing
352
+ // (transform contract, 2026-07-11). The return is held to the
353
+ // same record shape as the payload. A null/undefined return
354
+ // stays the intentional silent drop. A scalar or array return
355
+ // is one per-record CALLBACK_FAILED. The shape check runs only
356
+ // when a transform is configured, so the plain path pays
357
+ // nothing.
308
358
  let finalMessage;
309
- try {
310
- finalMessage = transform ? transform( message ) : message;
311
- } catch ( err ) {
312
- reporter.transformFailed( `topic '${topic}': transform threw: ${err.message} — message skipped` );
313
- return;
359
+ if ( guardedTransform ) {
360
+ finalMessage = guardedTransform( message, topic );
361
+ // The sentinel check runs FIRST: the sentinel is a plain
362
+ // object, so a later isUsableRecord check would wave it
363
+ // through into onMessage as a message.
364
+ if ( finalMessage === TRANSFORM_THREW ) {
365
+ return;
366
+ }
367
+ if ( finalMessage === null || finalMessage === undefined ) {
368
+ reporter.transformDropped();
369
+ return;
370
+ }
371
+ if ( !isUsableRecord( finalMessage ) ) {
372
+ reporter.transformFailed( `topic '${topic}': transform returned ${describeShape( finalMessage )} — a record object (or null/undefined to drop) is required — message skipped` );
373
+ return;
374
+ }
375
+ } else {
376
+ finalMessage = message;
314
377
  }
315
378
 
316
379
  // Deliver to handler
317
- if ( finalMessage !== null && finalMessage !== undefined ) {
318
- onMessage( finalMessage );
319
- reporter.delivered();
320
- } else {
321
- reporter.transformDropped();
322
- }
380
+ onMessage( finalMessage );
381
+ reporter.delivered();
323
382
  } );
324
383
 
325
384
  client.on( 'offline', function () {
@@ -87,7 +87,7 @@ import {
87
87
  */
88
88
  const assertPositiveInteger = function ( value, name ) {
89
89
  if ( typeof value !== 'number' || value < 1 || !Number.isInteger( value ) ) {
90
- const err = new Error( `WinkComposer/mqtt-source: ${name} must be a positive integer` );
90
+ const err = new Error( `winkComposer/mqttSource: ${name} must be a positive integer` );
91
91
  err.code = 'INVALID_CONFIG';
92
92
  throw err;
93
93
  }
@@ -114,7 +114,7 @@ const assertPositiveInteger = function ( value, name ) {
114
114
  */
115
115
  const createDedupCache = function ( options = {} ) {
116
116
  if ( typeof options !== 'object' || options === null ) {
117
- const err = new Error( 'WinkComposer/mqtt-source: dedup options must be an object' );
117
+ const err = new Error( 'winkComposer/mqttSource: dedup options must be an object' );
118
118
  err.code = 'INVALID_CONFIG';
119
119
  throw err;
120
120
  }
@@ -128,7 +128,7 @@ const createDedupCache = function ( options = {} ) {
128
128
  assertPositiveInteger( windowMs, 'windowMs' );
129
129
  assertPositiveInteger( maxEntries, 'maxEntries' );
130
130
  if ( typeof nowFn !== 'function' ) {
131
- const err = new Error( 'WinkComposer/mqtt-source: nowFn must be a function' );
131
+ const err = new Error( 'winkComposer/mqttSource: nowFn must be a function' );
132
132
  err.code = 'INVALID_CONFIG';
133
133
  throw err;
134
134
  }
@@ -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.