@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.
- package/package.json +8 -8
- package/src/core/batch/aoo-encoder.js +4 -4
- package/src/core/batch/sliding-window.js +2 -2
- package/src/core/codec/index.js +4 -4
- package/src/core/emitter-manager/mqtt/emitter.js +44 -14
- package/src/core/emitter-manager/mqtt/index.js +2 -1
- package/src/core/emitter-manager/terminal/assert-columns.js +1 -1
- package/src/core/emitter-manager/terminal/index.js +4 -4
- package/src/core/env-vars.js +44 -3
- package/src/core/logger/index.js +174 -0
- package/src/core/mqtt-protocol.js +2 -2
- package/src/core/partition-manager/get-stats.js +31 -0
- package/src/core/partition-manager/index.js +1 -0
- package/src/core/partition-manager/init.js +15 -0
- package/src/core/partition-manager/update.js +143 -87
- package/src/core/partition-manager/validate-flow.js +1 -1
- package/src/core/semantics/loader.js +1 -1
- package/src/core/semantics/warnings.js +5 -3
- package/src/core/shutdown-manager/index.js +71 -21
- package/src/core/source-manager/csv/start.js +109 -35
- package/src/core/source-manager/mqtt/client.js +95 -36
- package/src/core/source-manager/mqtt/dedup.js +3 -3
- package/src/core/source-manager/mqtt/index.js +1 -1
- package/src/core/source-manager/mqtt/status.js +67 -14
- package/src/core/source-manager/record-shape.js +55 -0
- package/src/core/source-manager/test-harness/field-generator.js +1 -1
- package/src/core/source-manager/test-harness/start.js +52 -10
- package/src/core/source-manager/test-harness/validate.js +1 -1
- package/src/core/storage-manager/questdb/assert-columns.js +2 -2
- package/src/core/storage-manager/questdb/ensure-tables.js +1 -1
- package/src/core/storage-manager/questdb/index.js +55 -17
- package/src/core/storage-manager/questdb/persist-plan.js +32 -8
- package/src/core/tunable/helpers.js +1 -1
- package/src/core/utils/callback-guard/index.js +155 -0
- package/src/core/utils/half-life/index.js +4 -4
- package/src/core/utils/validate/composer-validators.js +2 -2
- package/src/core/utils/validate/index.js +1 -1
- package/src/core/utils/validate/validate.js +1 -1
- package/src/core/wiring/assert-handle.js +5 -5
- package/src/core/wiring/assert-module.js +2 -2
- package/src/core/wiring/wire-emitters.js +4 -3
- package/src/core/wiring/wire-linear-graph.js +1 -1
- package/src/core/wiring/wire-node.js +6 -6
- package/src/core/wiring/wire-semantics.js +1 -1
- package/src/core/wiring/wire-storages.js +4 -3
- package/src/flow/driver.js +31 -6
- package/src/flow/expand-single-input-array.js +4 -4
- package/src/flow/flow-api-schemas.js +4 -4
- package/src/flow/flow.js +41 -41
- package/src/flow/for-each.js +12 -11
- package/src/flow/get-target-array.js +2 -2
- package/src/flow/load-node-module.js +1 -1
- package/src/flow/make-node-method.js +4 -4
- package/src/flow/resolve-pick-by-field.js +1 -1
- package/src/flow/run.js +176 -38
- package/src/flow/serialize.js +1 -1
- package/src/flow/spec-builder.js +1 -1
- package/src/flow/validate.js +1 -1
- package/src/nodes/butterworth-filter/init.js +5 -4
- package/src/nodes/categorize/update.js +3 -1
- package/src/nodes/controller/update.js +2 -1
- package/src/nodes/dwell-time-tracker/update.js +2 -1
- package/src/nodes/emit-if/helpers.js +57 -11
- package/src/nodes/emit-if/init.js +1 -1
- package/src/nodes/emit-if/update.js +8 -11
- package/src/nodes/page-hinkley/update.js +2 -1
- package/src/nodes/pass-if/update.js +3 -1
- package/src/nodes/persist-if/helpers.js +60 -4
- package/src/nodes/persist-if/init.js +1 -1
- package/src/nodes/persist-if/update.js +8 -16
- package/src/nodes/persistence-check/update.js +2 -1
- package/src/nodes/sanitize/update.js +4 -2
- package/src/nodes/swing-watch/update.js +3 -2
- package/src/nodes/threshold/update.js +2 -1
- package/src/nodes/trend/compute-confidence.js +4 -4
- package/src/nodes/trend/update.js +2 -1
- package/src/nodes/winnow/update.js +3 -1
- package/src/tools/training/features.js +13 -13
- package/src/tools/training/logistic.js +1 -1
- package/src/tools/training/metrics.js +2 -2
- package/src/tools/training/scale.js +2 -2
- package/src/tools/training/validate.js +7 -7
- package/src/core/emitter-manager/mqtt/mqtt-store.js +0 -538
- package/src/core/utils/console-formatter/index.js +0 -207
- package/src/nodes/archive/csv/index.js +0 -6
- package/src/nodes/archive/csv/init.js +0 -36
- package/src/nodes/archive/csv/load.js +0 -70
- package/src/nodes/archive/csv/reset.js +0 -14
- package/src/nodes/archive/csv/simple-parser.js +0 -216
- package/src/nodes/archive/csv/test.js +0 -173
- package/src/nodes/archive/csv/update.js +0 -63
- package/src/nodes/sse-emitter/index.js +0 -7
- package/src/nodes/sse-emitter/init.js +0 -105
- package/src/nodes/sse-emitter/introspect.js +0 -134
- package/src/nodes/sse-emitter/publish-to.js +0 -6
- package/src/nodes/sse-emitter/recompute.js +0 -16
- package/src/nodes/sse-emitter/reset.js +0 -19
- package/src/nodes/sse-emitter/update.js +0 -69
- package/src/nodes/sse-emitter/utils/broadcast-heartbeat.js +0 -36
- package/src/nodes/sse-emitter/utils/build-message.js +0 -31
- package/src/nodes/sse-emitter/utils/connection-manager.js +0 -45
- package/src/nodes/sse-emitter/utils/endpoint-initializer.js +0 -20
- package/src/nodes/sse-emitter/utils/health-monitor.js +0 -38
- 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` —
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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)
|
|
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 (
|
|
230
|
-
|
|
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
|
-
|
|
287
|
+
logger.error( `winkComposer/csvSource: decode failed [DECODE_ERROR]: ${message}` );
|
|
238
288
|
}
|
|
239
289
|
};
|
|
240
290
|
|
|
241
|
-
// A
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
|
262
|
-
//
|
|
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 ( !
|
|
328
|
+
if ( !guardedTransform ) {
|
|
265
329
|
return row;
|
|
266
330
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
442
|
-
|
|
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 →
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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:
|
|
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( '
|
|
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( '
|
|
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( '
|
|
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
|
-
|
|
215
|
-
'
|
|
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
|
|
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
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
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
|
-
|
|
310
|
-
finalMessage =
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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( `
|
|
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( '
|
|
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( '
|
|
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
|
|
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.
|