@winkjs/composer 0.4.2 → 0.5.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/README.md CHANGED
@@ -109,11 +109,15 @@ The numbers below come from a pure compute benchmark — every step a live messa
109
109
  | Configuration | Throughput |
110
110
  |----------------------|-------------------------|
111
111
  | Raspberry Pi 5 | ~100K messages/second |
112
- | Modern server | ~1.2M messages/second |
112
+ | MacBook Pro (M4 Max) | ~1.1M messages/second |
113
113
  | Tracking 10K assets | ~500K messages/second |
114
114
 
115
+ Both non-Pi rows were measured on a MacBook Pro (M4 Max) under Node.js 22. Every figure is single-core.
116
+
115
117
  Two configurations produced these numbers. The first two rows interleave 10 asset pipelines in random order — 4.5 million messages (10 pipelines × 900 data points × 500 iterations). The third row runs the same flow with 10,000 assets — 10,000 isolated states alive at once, 9 million messages, under 140 MB of heap. Pipelines are created dynamically as each asset first appears; timing uses `process.hrtime.bigint()`. The same pipeline [runs in your browser](https://composer.winkjs.org/docs/benchmark) — browser performance varies from native Node.js due to JIT differences.
116
118
 
119
+ Reproduce them from a repo clone. `node benchmark/compare.js 10 500` reports the median of three rounds. `node benchmark/run-benchmark.js static 10000 1` gives the 10K-asset row.
120
+
117
121
 
118
122
  ## Built for the Real World
119
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@winkjs/composer",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Composable Streaming Intelligence — turn streaming data into actionable insights, from edge to cloud",
5
5
  "keywords": [
6
6
  "streaming",
package/src/composer.js CHANGED
@@ -73,3 +73,12 @@ export {
73
73
  export { sigmoid, train, predict, classify } from './tools/training/logistic.js';
74
74
  export { confusionMatrix, classificationMetrics, sweepThresholds } from './tools/training/metrics.js';
75
75
 
76
+ // Stream-preparation utilities — ready-made functions for a source's
77
+ // `transform` option (see docs/handbook/stream-preparation.md)
78
+ export { coerceNumeric, coerceCell } from './tools/stream-prep/coerce-numeric.js';
79
+ export { normalizeTimestamp } from './tools/stream-prep/normalize-timestamp.js';
80
+ export { filterRows } from './tools/stream-prep/filter-rows.js';
81
+ export { labelShift, shiftLabelFor } from './tools/stream-prep/label-shift.js';
82
+ export { trackActivity } from './tools/stream-prep/track-activity.js';
83
+ export { stampPeriod } from './tools/stream-prep/stamp-period.js';
84
+
@@ -0,0 +1,94 @@
1
+ // src/tools/stream-prep/coerce-numeric.js
2
+
3
+ /**
4
+ * @fileoverview coerceNumeric — a stream-preparation utility for a source's
5
+ * `transform` option. Given a fixed list of fields, it rewrites each field on
6
+ * the message to a clean finite number or NaN, in place, allocating nothing
7
+ * per message.
8
+ *
9
+ * Why it exists: raw feeds (CSV replay, MQTT, a historian export) deliver
10
+ * blanks, empty strings, and out-of-range collector garbage mixed with real
11
+ * numbers. A downstream node's NaN-skip only works if "no value" reads as
12
+ * NaN, never 0 — and `Number( '' )` is 0 and `Number( null )` is 0, so a
13
+ * naive coercion silently invents zeros where data is missing. This maps
14
+ * every not-a-real-number to NaN explicitly, so a blank stays a blank.
15
+ *
16
+ * Scope is deliberately narrow and field-agnostic: it does NOT know any
17
+ * field's physical range. Field-specific validity (a -9999 code, a bath
18
+ * below 5 degC) is the `sanitize` node's job in the flow (the ADR-018
19
+ * boundary: a bad value in a parseable record is the pipeline's concern).
20
+ * This utility only guarantees the value is a finite number or NaN, plus an
21
+ * optional magnitude cut (`sentinelAbs`) for the huge collector garbage
22
+ * (e.g. 3.4e38) that is invalid for every field.
23
+ *
24
+ * Family contract (ADR-025, stream-preparation utilities): configuration is
25
+ * captured once at init; the returned function mutates the message in place,
26
+ * creates no objects per message, and returns the same reference, so it
27
+ * drops straight into a source `transform` slot ( row -> row ).
28
+ */
29
+
30
+ /**
31
+ * Coerce one raw cell to a number or NaN, without allocating. Empty string and
32
+ * null/undefined are "no value" -> NaN (never 0). A numeric string parses; a
33
+ * boolean maps to 1/0; anything unparseable is NaN.
34
+ *
35
+ * Known limitation: a whitespace-only string ( ' ' ) coerces to 0, matching
36
+ * `Number`'s own behaviour — detecting it would cost a per-cell allocation, and
37
+ * real feeds use '' or null for a missing cell. Add such tokens upstream if a
38
+ * feed genuinely encodes missing as whitespace.
39
+ *
40
+ * @param {*} value - the raw cell
41
+ * @returns {number} a number, possibly NaN
42
+ */
43
+ const coerceCell = function ( value ) {
44
+ if ( typeof value === 'number' ) {
45
+ return value;
46
+ }
47
+ if ( ( value === null ) || ( value === undefined ) || ( value === '' ) ) {
48
+ return NaN;
49
+ }
50
+ return Number( value );
51
+ }; // coerceCell()
52
+
53
+ /**
54
+ * Build an in-place numeric coercer for a fixed set of fields.
55
+ *
56
+ * @param {string[]} fields - message fields to coerce (non-empty array of
57
+ * non-empty strings; validated at init, fail-fast)
58
+ * @param {Object} [options]
59
+ * @param {number} [options.sentinelAbs] - if set, any coerced value whose
60
+ * magnitude is at or above this becomes NaN. A cut for universally-invalid
61
+ * collector garbage, NOT a per-field physical range (that lives in sanitize).
62
+ * @returns {function( Object ): Object} transform( msg ) -> msg, mutated in place
63
+ */
64
+ const coerceNumeric = function ( fields, options ) {
65
+ if ( !Array.isArray( fields ) || ( fields.length === 0 ) ) {
66
+ throw new Error( 'winkComposer/coerceNumeric: fields must be a non-empty array.' );
67
+ }
68
+ for ( let i = 0; i < fields.length; i += 1 ) {
69
+ if ( ( typeof fields[ i ] !== 'string' ) || ( fields[ i ] === '' ) ) {
70
+ throw new Error( 'winkComposer/coerceNumeric: every field must be a non-empty string.' );
71
+ }
72
+ }
73
+ const opts = options || {};
74
+ const sentinelAbs = ( opts.sentinelAbs === undefined ) ? Infinity : opts.sentinelAbs;
75
+ if ( ( typeof sentinelAbs !== 'number' ) || Number.isNaN( sentinelAbs ) || ( sentinelAbs <= 0 ) ) {
76
+ throw new Error( 'winkComposer/coerceNumeric: sentinelAbs must be a positive number.' );
77
+ }
78
+ // Private copy so the caller cannot mutate the field list after init; index
79
+ // iteration over a stable local keeps the hot path free of iterator objects.
80
+ const fieldList = fields.slice();
81
+ const n = fieldList.length;
82
+
83
+ return function ( msg ) {
84
+ for ( let i = 0; i < n; i += 1 ) {
85
+ const field = fieldList[ i ];
86
+ const num = coerceCell( msg[ field ] );
87
+ msg[ field ] = ( Number.isFinite( num ) && ( Math.abs( num ) < sentinelAbs ) ) ? num : NaN;
88
+ }
89
+ return msg;
90
+ };
91
+ }; // coerceNumeric()
92
+
93
+ export { coerceCell, coerceNumeric };
94
+ export default coerceNumeric;
@@ -0,0 +1,96 @@
1
+ // src/tools/stream-prep/filter-rows.js
2
+
3
+ /**
4
+ * @fileoverview filterRows — a stream-preparation utility for a source's
5
+ * `transform` option. Keeps only the rows whose timestamp falls inside an
6
+ * inclusive time window, by returning null for everything outside it. The
7
+ * source counts each null as a skipped record, so drops stay visible.
8
+ *
9
+ * Why it exists: replay work — demos, evaluations, byte-identity reruns —
10
+ * almost always studies a window of a longer recording. Field flows carried
11
+ * the same two-compare window filter as inline code many times over; this is
12
+ * that filter, once. A live production feed normally has no use for it.
13
+ *
14
+ * The bounds are a Knob of the run, fixed at init: `from` and `to` accept an
15
+ * epoch-ms number or an ISO-8601 string (parsed once, at init — never per
16
+ * row). The hot path is two number compares against the timestamp field.
17
+ * Run it AFTER `normalizeTimestamp`, so the field already holds epoch ms.
18
+ *
19
+ * A row whose timestamp is not a finite number cannot be placed inside or
20
+ * outside the window, so it is dropped too. Rows with a bad clock never
21
+ * reach the flow through this filter.
22
+ *
23
+ * Deliberately NOT here: arbitrary predicates. The `transform` hook itself
24
+ * is already "return null to drop", so a custom predicate needs no utility.
25
+ * This one earns its place only by owning the bounds parsing and the
26
+ * inclusive-window rule.
27
+ *
28
+ * Family contract (ADR-025, stream-preparation utilities): config captured
29
+ * at init; zero per-row allocation; returns the same message reference, or
30
+ * null to drop ( row -> row | null ).
31
+ */
32
+
33
+ /**
34
+ * Resolve one bound to epoch ms at init. Numbers must be finite; strings go
35
+ * through `Date.parse` once. Anything else, or an unparseable string, throws.
36
+ *
37
+ * @param {*} value - the bound as given
38
+ * @param {string} name - option name for the error message
39
+ * @returns {number} epoch ms
40
+ */
41
+ const resolveBound = function ( value, name ) {
42
+ if ( typeof value === 'number' ) {
43
+ if ( !Number.isFinite( value ) ) {
44
+ throw new Error( 'winkComposer/filterRows: ' + name + ' must be a finite epoch-ms number or a parseable date string.' );
45
+ }
46
+ return value;
47
+ }
48
+ if ( typeof value === 'string' ) {
49
+ const parsed = Date.parse( value );
50
+ if ( !Number.isFinite( parsed ) ) {
51
+ throw new Error( 'winkComposer/filterRows: ' + name + ' is not a parseable date string (got \'' + value + '\').' );
52
+ }
53
+ return parsed;
54
+ }
55
+ throw new Error( 'winkComposer/filterRows: ' + name + ' must be a finite epoch-ms number or a parseable date string.' );
56
+ }; // resolveBound()
57
+
58
+ /**
59
+ * Build an inclusive time-window row filter.
60
+ *
61
+ * @param {Object} options
62
+ * @param {string} [options.field='timestamp'] - field holding epoch ms
63
+ * (normalize it first — see normalizeTimestamp).
64
+ * @param {number|string} [options.from] - inclusive lower bound (epoch ms or
65
+ * ISO-8601 string). At least one of from/to is required.
66
+ * @param {number|string} [options.to] - inclusive upper bound (epoch ms or
67
+ * ISO-8601 string).
68
+ * @returns {function( Object ): (Object|null)} transform( msg ) -> msg inside
69
+ * the window, null outside it. A non-finite timestamp also returns null.
70
+ */
71
+ const filterRows = function ( options ) {
72
+ const opts = options || {};
73
+ const field = ( opts.field === undefined ) ? 'timestamp' : opts.field;
74
+ if ( ( typeof field !== 'string' ) || ( field === '' ) ) {
75
+ throw new Error( 'winkComposer/filterRows: field must be a non-empty string.' );
76
+ }
77
+ if ( ( opts.from === undefined ) && ( opts.to === undefined ) ) {
78
+ throw new Error( 'winkComposer/filterRows: at least one of from/to is required.' );
79
+ }
80
+ const fromMs = ( opts.from === undefined ) ? -Infinity : resolveBound( opts.from, 'from' );
81
+ const toMs = ( opts.to === undefined ) ? Infinity : resolveBound( opts.to, 'to' );
82
+ if ( fromMs > toMs ) {
83
+ throw new Error( 'winkComposer/filterRows: from must not be later than to.' );
84
+ }
85
+
86
+ return function ( msg ) {
87
+ const t = msg[ field ];
88
+ if ( !Number.isFinite( t ) ) {
89
+ return null;
90
+ }
91
+ return ( ( t >= fromMs ) && ( t <= toMs ) ) ? msg : null;
92
+ };
93
+ }; // filterRows()
94
+
95
+ export { filterRows };
96
+ export default filterRows;
@@ -0,0 +1,112 @@
1
+ // src/tools/stream-prep/label-shift.js
2
+
3
+ /**
4
+ * @fileoverview labelShift — a stream-preparation utility for a source's
5
+ * `transform` option. Stamps each message with the production shift it falls
6
+ * in (e.g. S1 / S2 / S3), derived from the event timestamp, in place, with no
7
+ * per-message allocation.
8
+ *
9
+ * The shift schedule is a deployment Knob, not a universal fact — sites
10
+ * differ in shift count, length, start time, and labels. So everything is
11
+ * config: give it the schedule, it labels any feed.
12
+ *
13
+ * Why alloc-free matters and how: the obvious implementation, `new Date( ms )
14
+ * .getUTCHours()`, allocates a Date object every message — unacceptable on
15
+ * the hot path. Instead this reduces the epoch to a minute-of-day by integer
16
+ * modulo and picks the shift by a short scan over the boundary list. All
17
+ * configuration (offset, boundaries, labels) is captured once at init; the
18
+ * returned function reads/writes the message in place, creates no objects,
19
+ * and returns the same message so it drops into a source `transform` slot
20
+ * ( row -> row ) — the ADR-025 family contract.
21
+ *
22
+ * Local time is a FIXED offset from UTC. Daylight-saving sites are out of
23
+ * scope: a fixed offset cannot follow a DST change, and the timezone
24
+ * machinery that could would allocate per message. India (IST, +5:30)
25
+ * observes no DST; a DST site must resolve local time upstream.
26
+ *
27
+ * The shift schedule is given as `boundariesMin` — the minute-of-day at which
28
+ * each shift starts, ascending — and matching `labels`. A time earlier than
29
+ * the first boundary belongs to the last shift (it wrapped past midnight), so
30
+ * a schedule whose first shift starts at 06:00 labels the small hours
31
+ * correctly.
32
+ */
33
+
34
+ import { fieldNameOr, finiteNumberOr, validatedBoundaries } from './validate.js';
35
+
36
+ const MS_PER_MIN = 60000;
37
+ const MS_PER_DAY = 86400000;
38
+
39
+ /**
40
+ * Pure shift lookup: which shift an epoch-ms instant falls in. Reduces the instant to
41
+ * a local minute-of-day (fixed offset, integer modulo — no Date object) and returns
42
+ * the label of the last boundary at or below it; a time before the first boundary
43
+ * wraps to the last shift. Shared by the hot-path labeler below and by any caller that
44
+ * needs the shift of a one-off instant (e.g. the shift that just ended at a boundary).
45
+ *
46
+ * @param {number} t - epoch ms; a non-finite value returns null (no shift known).
47
+ * @param {number} offsetMinutes - minutes to add to reach local wall-clock (IST = 330).
48
+ * @param {number[]} boundariesMin - shift start minutes-of-day, ascending.
49
+ * @param {string[]} labels - one label per boundary.
50
+ * @returns {string|null} the shift label, or null for a non-finite instant.
51
+ */
52
+ const shiftLabelFor = function ( t, offsetMinutes, boundariesMin, labels ) {
53
+ if ( !Number.isFinite( t ) ) {
54
+ return null;
55
+ }
56
+ let intoDay = ( t + ( offsetMinutes * MS_PER_MIN ) ) % MS_PER_DAY;
57
+ if ( intoDay < 0 ) {
58
+ intoDay += MS_PER_DAY;
59
+ }
60
+ const minute = Math.floor( intoDay / MS_PER_MIN );
61
+ let idx = labels.length - 1;
62
+ for ( let i = 0; i < boundariesMin.length; i += 1 ) {
63
+ if ( minute >= boundariesMin[ i ] ) {
64
+ idx = i;
65
+ }
66
+ }
67
+ return labels[ idx ];
68
+ }; // shiftLabelFor()
69
+
70
+ /**
71
+ * Build an in-place shift labeler.
72
+ *
73
+ * @param {Object} options
74
+ * @param {number[]} options.boundariesMin - shift start minutes-of-day, strictly
75
+ * ascending, each in [0, 1440). Required. (e.g. [ 0, 480, 960 ] for three
76
+ * 8-hour shifts from local midnight.)
77
+ * @param {string[]} options.labels - shift labels, one per boundary, same length.
78
+ * Required. (e.g. [ 'S1', 'S2', 'S3' ].)
79
+ * @param {number} [options.offsetMinutes=0] - minutes to add to epoch-UTC to reach
80
+ * local wall-clock time (IST = 330).
81
+ * @param {string} [options.field='timestamp'] - source field holding epoch ms.
82
+ * @param {string} [options.target='shiftLabel'] - field to write the label to.
83
+ * @returns {function( Object ): Object} transform( msg ) -> msg, mutated in place.
84
+ * A non-finite timestamp writes null (no shift known).
85
+ */
86
+ const labelShift = function ( options ) {
87
+ const opts = options || {};
88
+ const field = fieldNameOr( opts.field, 'timestamp', 'labelShift', 'field' );
89
+ const target = fieldNameOr( opts.target, 'shiftLabel', 'labelShift', 'target' );
90
+ const offsetMinutes = finiteNumberOr( opts.offsetMinutes, 0, 'labelShift', 'offsetMinutes' );
91
+ // Private copies from the validators; the hot path touches only these
92
+ // locals and the shared lookup.
93
+ const bounds = validatedBoundaries( opts.boundariesMin, 'labelShift' );
94
+ const labels = opts.labels;
95
+ if ( !Array.isArray( labels ) || ( labels.length !== bounds.length ) ) {
96
+ throw new Error( 'winkComposer/labelShift: labels must be an array the same length as boundariesMin.' );
97
+ }
98
+ for ( let i = 0; i < labels.length; i += 1 ) {
99
+ if ( ( typeof labels[ i ] !== 'string' ) || ( labels[ i ] === '' ) ) {
100
+ throw new Error( 'winkComposer/labelShift: every label must be a non-empty string.' );
101
+ }
102
+ }
103
+ const names = labels.slice();
104
+
105
+ return function ( msg ) {
106
+ msg[ target ] = shiftLabelFor( msg[ field ], offsetMinutes, bounds, names );
107
+ return msg;
108
+ };
109
+ }; // labelShift()
110
+
111
+ export { labelShift, shiftLabelFor };
112
+ export default labelShift;
@@ -0,0 +1,265 @@
1
+ // src/tools/stream-prep/normalize-timestamp.js
2
+
3
+ /**
4
+ * @fileoverview normalizeTimestamp — a stream-preparation utility for a
5
+ * source's `transform` option. Guarantees one message field holds a numeric
6
+ * epoch-millisecond timestamp, in place, with no per-message allocation.
7
+ *
8
+ * Why it exists: every downstream window, dwell, and control node keys off
9
+ * event time, and raw feeds carry it in different shapes — epoch
10
+ * milliseconds, epoch seconds, an ISO-8601 string, or a plant historian's
11
+ * zone-less 'YYYY-MM-DD HH:mm:ss' text. A flow should not branch on which;
12
+ * this converts the declared field to epoch ms once, at the inlet.
13
+ *
14
+ * The input shape is fixed at init (a Knob of the feed, not sniffed per
15
+ * row), so the hot path carries no shape-detection branch. Two ways to
16
+ * declare it:
17
+ *
18
+ * unit: 'ms' the value is already epoch milliseconds
19
+ * unit: 's' the value is epoch seconds -> multiplied by 1000
20
+ * unit: 'auto' numbers pass through as ms; strings go to `Date.parse`
21
+ * pattern the value is text in EXACTLY this layout; parsed by a
22
+ * compiled reader with a declared fixed UTC offset
23
+ *
24
+ * The pattern path exists because zone-less historian text is the single
25
+ * most common field shape, and `Date.parse` on it is engine-dependent. The
26
+ * one supported pattern is 'YYYY-MM-DD HH:mm:ss'; the data may carry a
27
+ * trailing '.SSS' fraction (any number of digits; milliseconds kept, the
28
+ * rest truncated). The reader walks the string by character code and builds
29
+ * the epoch with the days-from-civil calendar algorithm (Howard Hinnant,
30
+ * "chrono-Compatible Low-Level Date Algorithms",
31
+ * https://howardhinnant.github.io/date_algorithms.html) — no `replace`, no
32
+ * `Date` object, no `Date.parse`, nothing allocated per row.
33
+ * `offsetMinutes` states the wall clock the text was written in (IST =
34
+ * 330); the epoch is shifted back to true UTC. Fixed offset only — DST
35
+ * sites are out of scope, same rule as labelShift.
36
+ *
37
+ * An empty or unparseable value becomes NaN, never a throw and never 0; the
38
+ * caller decides whether to drop the row (see filterRows).
39
+ *
40
+ * Family contract (ADR-025, stream-preparation utilities): converter and
41
+ * field names are captured at init; the returned function reads/writes the
42
+ * message in place and returns the same reference ( row -> row ).
43
+ */
44
+
45
+ import { fieldNameOr, finiteNumberOr } from './validate.js';
46
+
47
+ const MS_PER_SEC = 1000;
48
+ const MS_PER_MIN = 60000;
49
+ const MS_PER_HOUR = 3600000;
50
+ const MS_PER_DAY = 86400000;
51
+ const ZERO_CODE = 48;
52
+ const SUPPORTED_PATTERN = 'YYYY-MM-DD HH:mm:ss';
53
+
54
+ // Days in each month (index 1-12) of a non-leap year; February's leap case is
55
+ // handled at the day-of-month validity check.
56
+ const DAYS_IN_MONTH = [ 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ];
57
+
58
+ /**
59
+ * Coerce a raw cell to a number or NaN without allocating. Empty string and
60
+ * null/undefined are "no value" -> NaN (never 0), so an empty seconds cell does
61
+ * not become epoch 0.
62
+ *
63
+ * @param {*} value - the raw cell
64
+ * @returns {number} a number, possibly NaN
65
+ */
66
+ const toNum = function ( value ) {
67
+ if ( typeof value === 'number' ) {
68
+ return value;
69
+ }
70
+ if ( ( value === null ) || ( value === undefined ) || ( value === '' ) ) {
71
+ return NaN;
72
+ }
73
+ return Number( value );
74
+ }; // toNum()
75
+
76
+ /**
77
+ * Read a fixed-width run of ASCII digits as an integer; -1 when any position
78
+ * is not a digit. The -1 sentinel keeps the caller's malformed-input path a
79
+ * plain compare, with no exception machinery on the hot path.
80
+ */
81
+ const readDigits = function ( s, start, len ) {
82
+ let out = 0;
83
+ for ( let i = start; i < ( start + len ); i += 1 ) {
84
+ const d = s.charCodeAt( i ) - ZERO_CODE;
85
+ if ( ( d < 0 ) || ( d > 9 ) ) {
86
+ return -1;
87
+ }
88
+ out = ( out * 10 ) + d;
89
+ }
90
+ return out;
91
+ }; // readDigits()
92
+
93
+ /** Gregorian leap-year rule. */
94
+ const isLeapYear = function ( y ) {
95
+ return ( ( ( y % 4 ) === 0 ) && ( ( y % 100 ) !== 0 ) ) || ( ( y % 400 ) === 0 );
96
+ }; // isLeapYear()
97
+
98
+ /** True for a real calendar date (readDigits' -1 fails the range checks). */
99
+ const isValidDate = function ( year, month, day ) {
100
+ if ( ( year < 0 ) || ( month < 1 ) || ( month > 12 ) || ( day < 1 ) ) {
101
+ return false;
102
+ }
103
+ const maxDay = ( ( month === 2 ) && isLeapYear( year ) ) ? 29 : DAYS_IN_MONTH[ month ];
104
+ return day <= maxDay;
105
+ }; // isValidDate()
106
+
107
+ /** True for a real time of day (readDigits' -1 fails the range checks). */
108
+ const isValidTime = function ( hour, minute, second ) {
109
+ return ( hour >= 0 ) && ( hour <= 23 ) &&
110
+ ( minute >= 0 ) && ( minute <= 59 ) &&
111
+ ( second >= 0 ) && ( second <= 59 );
112
+ }; // isValidTime()
113
+
114
+ /**
115
+ * Read the optional '.frac' tail starting at position 19: '.' plus one or
116
+ * more digits. Returns milliseconds (first three digits; the rest validated
117
+ * but truncated), or -1 when the tail is malformed.
118
+ */
119
+ const readFraction = function ( s, len ) {
120
+ if ( ( s.charCodeAt( 19 ) !== 46 ) || ( len === 20 ) ) {
121
+ return -1;
122
+ }
123
+ let frac = 0;
124
+ let scale = 100;
125
+ for ( let i = 20; i < len; i += 1 ) {
126
+ const d = s.charCodeAt( i ) - ZERO_CODE;
127
+ if ( ( d < 0 ) || ( d > 9 ) ) {
128
+ return -1;
129
+ }
130
+ if ( scale >= 1 ) {
131
+ frac += d * scale;
132
+ scale = ( scale === 1 ) ? 0 : ( scale / 10 );
133
+ }
134
+ }
135
+ return frac;
136
+ }; // readFraction()
137
+
138
+ /**
139
+ * Days from the civil epoch (1970-01-01) for a valid calendar date. This is
140
+ * Howard Hinnant's days_from_civil, an exact integer algorithm — see
141
+ * https://howardhinnant.github.io/date_algorithms.html.
142
+ */
143
+ const daysFromCivil = function ( year, month, day ) {
144
+ const y = ( month <= 2 ) ? ( year - 1 ) : year;
145
+ const era = Math.floor( y / 400 );
146
+ const yoe = y - ( era * 400 );
147
+ const mp = ( month + 9 ) % 12;
148
+ const doy = Math.floor( ( ( 153 * mp ) + 2 ) / 5 ) + ( day - 1 );
149
+ const doe = ( yoe * 365 ) + Math.floor( yoe / 4 ) - Math.floor( yoe / 100 ) + doy;
150
+ return ( era * 146097 ) + doe - 719468;
151
+ }; // daysFromCivil()
152
+
153
+ /**
154
+ * Compile the reader for the one supported pattern. Returns a function that
155
+ * parses 'YYYY-MM-DD HH:mm:ss[.frac]' text written in a fixed-offset wall
156
+ * clock to true-UTC epoch ms, or NaN for anything malformed.
157
+ *
158
+ * @param {number} offsetMs - wall-clock offset from UTC in ms (IST = 330 min)
159
+ * @returns {function( * ): number} value -> epoch ms | NaN
160
+ */
161
+ const compilePatternReader = function ( offsetMs ) {
162
+ return function ( value ) {
163
+ if ( ( typeof value !== 'string' ) || ( value.length < 19 ) ) {
164
+ return NaN;
165
+ }
166
+ const len = value.length;
167
+ // Fixed separators first — cheapest rejection for a wrong layout.
168
+ if ( ( value.charCodeAt( 4 ) !== 45 ) || ( value.charCodeAt( 7 ) !== 45 ) ||
169
+ ( value.charCodeAt( 10 ) !== 32 ) || ( value.charCodeAt( 13 ) !== 58 ) ||
170
+ ( value.charCodeAt( 16 ) !== 58 ) ) {
171
+ return NaN;
172
+ }
173
+ const year = readDigits( value, 0, 4 );
174
+ const month = readDigits( value, 5, 2 );
175
+ const day = readDigits( value, 8, 2 );
176
+ const hour = readDigits( value, 11, 2 );
177
+ const minute = readDigits( value, 14, 2 );
178
+ const second = readDigits( value, 17, 2 );
179
+ if ( !isValidDate( year, month, day ) || !isValidTime( hour, minute, second ) ) {
180
+ return NaN;
181
+ }
182
+ const frac = ( len > 19 ) ? readFraction( value, len ) : 0;
183
+ if ( frac < 0 ) {
184
+ return NaN;
185
+ }
186
+ const days = daysFromCivil( year, month, day );
187
+ return ( days * MS_PER_DAY ) + ( hour * MS_PER_HOUR ) + ( minute * MS_PER_MIN ) +
188
+ ( second * MS_PER_SEC ) + frac - offsetMs;
189
+ };
190
+ }; // compilePatternReader()
191
+
192
+ // One converter per input shape, chosen once at init so the hot path never
193
+ // branches on the mode string. Dictionary with an externally-supplied key, so
194
+ // Object.create( null ) guards against inherited-property lookups (e.g. a mode of
195
+ // 'constructor' resolving to Object). Each converter returns epoch ms or NaN.
196
+ const CONVERTERS = Object.create( null );
197
+ CONVERTERS.ms = function ( value ) {
198
+ return toNum( value );
199
+ };
200
+ CONVERTERS.s = function ( value ) {
201
+ return toNum( value ) * MS_PER_SEC;
202
+ };
203
+ CONVERTERS.auto = function ( value ) {
204
+ return ( typeof value === 'number' ) ? value : Date.parse( value );
205
+ };
206
+
207
+ /**
208
+ * Pick the converter from the options: a compiled pattern reader when
209
+ * `pattern` is declared, one of the unit converters otherwise. Init-time
210
+ * only; all option conflicts are rejected here.
211
+ */
212
+ const resolveConverter = function ( opts ) {
213
+ if ( opts.pattern === undefined ) {
214
+ if ( opts.offsetMinutes !== undefined ) {
215
+ throw new Error( 'winkComposer/normalizeTimestamp: offsetMinutes applies to pattern only.' );
216
+ }
217
+ const unit = ( opts.unit === undefined ) ? 'auto' : opts.unit;
218
+ const convert = CONVERTERS[ unit ];
219
+ if ( typeof convert !== 'function' ) {
220
+ throw new Error( 'winkComposer/normalizeTimestamp: unit must be one of ms, s, auto (got ' + unit + ').' );
221
+ }
222
+ return convert;
223
+ }
224
+ if ( opts.unit !== undefined ) {
225
+ throw new Error( 'winkComposer/normalizeTimestamp: unit and pattern are mutually exclusive.' );
226
+ }
227
+ if ( opts.pattern !== SUPPORTED_PATTERN ) {
228
+ throw new Error( 'winkComposer/normalizeTimestamp: the supported pattern is \'' + SUPPORTED_PATTERN + '\' (got ' + opts.pattern + ').' );
229
+ }
230
+ const offsetMinutes = finiteNumberOr( opts.offsetMinutes, 0, 'normalizeTimestamp', 'offsetMinutes' );
231
+ return compilePatternReader( offsetMinutes * MS_PER_MIN );
232
+ }; // resolveConverter()
233
+
234
+ /**
235
+ * Build an in-place timestamp normalizer.
236
+ *
237
+ * @param {Object} [options]
238
+ * @param {string} [options.field='timestamp'] - source field to read
239
+ * @param {string} [options.target=field] - field to write; defaults to the source
240
+ * field (in place). Set it when the raw column name differs from the field the
241
+ * flow reads (e.g. read 'ts', write 'timestamp').
242
+ * @param {string} [options.unit='auto'] - input shape: 'ms' | 's' | 'auto'.
243
+ * Mutually exclusive with pattern.
244
+ * @param {string} [options.pattern] - fixed text layout; the one supported
245
+ * value is 'YYYY-MM-DD HH:mm:ss' (data may carry a trailing '.SSS'
246
+ * fraction). Mutually exclusive with unit.
247
+ * @param {number} [options.offsetMinutes=0] - with pattern only: the fixed
248
+ * UTC offset of the wall clock the text was written in (IST = 330).
249
+ * @returns {function( Object ): Object} transform( msg ) -> msg, mutated in place
250
+ */
251
+ const normalizeTimestamp = function ( options ) {
252
+ const opts = options || {};
253
+ const field = fieldNameOr( opts.field, 'timestamp', 'normalizeTimestamp', 'field' );
254
+ const target = fieldNameOr( opts.target, field, 'normalizeTimestamp', 'target' );
255
+ const convert = resolveConverter( opts );
256
+
257
+ return function ( msg ) {
258
+ const out = convert( msg[ field ] );
259
+ msg[ target ] = Number.isFinite( out ) ? out : NaN;
260
+ return msg;
261
+ };
262
+ }; // normalizeTimestamp()
263
+
264
+ export { normalizeTimestamp };
265
+ export default normalizeTimestamp;
@@ -0,0 +1,111 @@
1
+ // src/tools/stream-prep/stamp-period.js
2
+
3
+ /**
4
+ * @fileoverview stampPeriod — a stream-preparation utility for a source's
5
+ * `transform` option. Stamps each message with a monotonic integer key naming
6
+ * the calendar period — the local day, or the shift — its timestamp falls in.
7
+ * In place, with no per-message allocation.
8
+ *
9
+ * Why a key and not a "period rolled" flag: the roll (the first message of a
10
+ * new period, per asset) needs per-asset state, and per-asset state belongs
11
+ * to partitions. With the key on the message, a per-partition
12
+ * `stateChangeDetector` on it gives the per-asset roll edge in-flow, and a
13
+ * `controller` can flush windows on it. The utility stays stateless and
14
+ * stream-level; the flow owns the roll.
15
+ *
16
+ * The keys and their arithmetic (all integer, no Date objects):
17
+ * 'day' dayIndex = floor( (t + offset) / 86400000 ) — days since the
18
+ * epoch, counted in local wall-clock time (fixed offset).
19
+ * 'shift' shiftKey = (dayIndex * shiftsPerDay) + shiftIdx — unique and
20
+ * monotonic across days. A time earlier than the first boundary
21
+ * belongs to the PREVIOUS day's last shift (it wrapped past
22
+ * midnight), so the key never jumps backward at a boundary.
23
+ *
24
+ * Local time is a FIXED offset from UTC, same rule as labelShift: DST sites
25
+ * are out of scope; resolve local time upstream there.
26
+ *
27
+ * Family contract (ADR-025, stream-preparation utilities): configuration is
28
+ * captured once at init; the returned function mutates the message in place,
29
+ * creates no objects per message, and returns the same reference
30
+ * ( row -> row ). A non-finite timestamp writes null (no period known).
31
+ */
32
+
33
+ import { fieldNameOr, finiteNumberOr, validatedBoundaries } from './validate.js';
34
+
35
+ const MS_PER_MIN = 60000;
36
+ const MS_PER_DAY = 86400000;
37
+
38
+ /**
39
+ * Build an in-place period-key stamper.
40
+ *
41
+ * @param {Object} options
42
+ * @param {string} options.period - 'day' or 'shift'. Required.
43
+ * @param {number[]} [options.boundariesMin] - shift start minutes-of-day,
44
+ * strictly ascending, each in [0, 1440). Required for 'shift';
45
+ * not allowed for 'day'.
46
+ * @param {number} [options.offsetMinutes=0] - minutes to add to epoch-UTC to
47
+ * reach local wall-clock time (IST = 330).
48
+ * @param {string} [options.field='timestamp'] - source field holding epoch ms.
49
+ * @param {string} [options.target] - field to write the key to. Defaults to
50
+ * 'dayKey' or 'shiftKey' by period.
51
+ * @returns {function( Object ): Object} transform( msg ) -> msg, mutated in
52
+ * place. A non-finite timestamp writes null.
53
+ */
54
+ const stampPeriod = function ( options ) {
55
+ const opts = options || {};
56
+ const period = opts.period;
57
+ if ( ( period !== 'day' ) && ( period !== 'shift' ) ) {
58
+ throw new Error( 'winkComposer/stampPeriod: period must be day or shift.' );
59
+ }
60
+ const field = fieldNameOr( opts.field, 'timestamp', 'stampPeriod', 'field' );
61
+ const defaultTarget = ( period === 'day' ) ? 'dayKey' : 'shiftKey';
62
+ const target = fieldNameOr( opts.target, defaultTarget, 'stampPeriod', 'target' );
63
+ const offsetMinutes = finiteNumberOr( opts.offsetMinutes, 0, 'stampPeriod', 'offsetMinutes' );
64
+ const offsetMs = offsetMinutes * MS_PER_MIN;
65
+
66
+ if ( period === 'day' ) {
67
+ if ( opts.boundariesMin !== undefined ) {
68
+ throw new Error( 'winkComposer/stampPeriod: boundariesMin applies to shift only.' );
69
+ }
70
+ return function ( msg ) {
71
+ const t = msg[ field ];
72
+ msg[ target ] = Number.isFinite( t ) ? Math.floor( ( t + offsetMs ) / MS_PER_DAY ) : null;
73
+ return msg;
74
+ };
75
+ }
76
+
77
+ // period === 'shift' — the schedule is validated exactly as labelShift's.
78
+ const bounds = validatedBoundaries( opts.boundariesMin, 'stampPeriod' );
79
+ const shiftsPerDay = bounds.length;
80
+
81
+ return function ( msg ) {
82
+ const t = msg[ field ];
83
+ if ( !Number.isFinite( t ) ) {
84
+ msg[ target ] = null;
85
+ return msg;
86
+ }
87
+ const local = t + offsetMs;
88
+ const dayIndex = Math.floor( local / MS_PER_DAY );
89
+ let intoDay = local % MS_PER_DAY;
90
+ if ( intoDay < 0 ) {
91
+ intoDay += MS_PER_DAY;
92
+ }
93
+ const minute = Math.floor( intoDay / MS_PER_MIN );
94
+ if ( minute < bounds[ 0 ] ) {
95
+ // Wrapped past midnight: previous day's last shift, keeping the key monotonic.
96
+ msg[ target ] = ( ( dayIndex - 1 ) * shiftsPerDay ) + ( shiftsPerDay - 1 );
97
+ return msg;
98
+ }
99
+ let idx = 0;
100
+ for ( let i = 0; i < shiftsPerDay; i += 1 ) {
101
+ if ( minute >= bounds[ i ] ) {
102
+ idx = i;
103
+ }
104
+ }
105
+ msg[ target ] = ( dayIndex * shiftsPerDay ) + idx;
106
+ return msg;
107
+ };
108
+ }; // stampPeriod()
109
+
110
+ export { stampPeriod };
111
+ export default stampPeriod;
@@ -0,0 +1,223 @@
1
+ // src/tools/stream-prep/track-activity.js
2
+
3
+ /**
4
+ * @fileoverview trackActivity — a stream-preparation utility for a source's
5
+ * `transform` option. It turns an intermittent activity signal into a
6
+ * sustained active/idle state and reports how long that state has held, in
7
+ * place, allocating nothing per message.
8
+ *
9
+ * Why it exists: many plant questions reduce to "has this thing shown
10
+ * activity recently, and for how long?" — is the conveyor line running (a
11
+ * pulse toggled lately), is a sensor dead or stuck (its value has not changed
12
+ * in a long time), is a machine idle (its meter has stopped advancing). These
13
+ * are one measurement read from two ends: track the time since the last
14
+ * activity; recent means active, stale means idle/dead. This utility is that
15
+ * one measurement.
16
+ *
17
+ * What counts as "activity": a watched field's value CHANGED since the
18
+ * previous message. With several fields, any one changing is activity.
19
+ * `epsilon` ignores a numeric change smaller than itself (consecutive-step
20
+ * jitter). A field with no value this message (null / undefined / NaN)
21
+ * contributes no activity and does not disturb the baseline.
22
+ *
23
+ * The state model: hold "active" for `windowSec` after the last activity.
24
+ * `active` starts false and turns true only once a change has actually been
25
+ * observed — a gate never claims "running" without evidence, and a signal
26
+ * that never changes reads as never-active (dead). Reported per message:
27
+ * active is it active now (a change within the window)
28
+ * activeFor ms the current active run has lasted (0 when idle)
29
+ * sinceActivity ms since the last activity (null before any; the "stale" end)
30
+ * activeStart epoch-ms clock reading when the current active run began. Unlike
31
+ * the others it HOLDS its value while idle, keeping the last run's
32
+ * start, so an event that ends after the run stopped can still be
33
+ * dated back to the run it belonged to (null before any activity).
34
+ *
35
+ * Scope: this is for a STREAM-LEVEL / shared signal (a line, a plant)
36
+ * computed once upstream of partitioning. Because it compares to the previous
37
+ * message, it is correct when the watched fields are the same across a tick's
38
+ * messages (as shared line pulses are) — the change is caught once on the
39
+ * tick boundary and every message of that tick gets the same reading.
40
+ * Per-asset activity (a different value per message) needs per-key state,
41
+ * which is out of scope here.
42
+ *
43
+ * Family contract (ADR-025): fields, window, and output names are captured at
44
+ * init; the previous-value store is one object built once. The returned
45
+ * function reads and writes the message in place, creates nothing per
46
+ * message, and returns the same message so it drops into a source `transform`
47
+ * slot ( row -> row ). Durations are clamped to >= 0; timestamps are assumed
48
+ * monotonic per stream.
49
+ */
50
+
51
+ import { fieldNameOr, finiteNumberOr } from './validate.js';
52
+
53
+ /** True when a value is "no value this message" — never counts as activity. */
54
+ const isNoValue = function ( v ) {
55
+ return ( v === null ) || ( v === undefined ) || ( ( typeof v === 'number' ) && Number.isNaN( v ) );
56
+ }; // isNoValue()
57
+
58
+ /**
59
+ * Whether a value differs from its predecessor. Numbers compare by magnitude
60
+ * against `epsilon` (jitter gate); anything else compares strictly.
61
+ */
62
+ const differs = function ( cur, prev, epsilon ) {
63
+ if ( ( typeof cur === 'number' ) && ( typeof prev === 'number' ) ) {
64
+ return Math.abs( cur - prev ) > epsilon;
65
+ }
66
+ return cur !== prev;
67
+ }; // differs()
68
+
69
+ const ALLOWED_WRITES = [ 'active', 'activeFor', 'sinceActivity', 'activeStart' ];
70
+
71
+ /**
72
+ * Resolve the `from` option to a private array of watched field names.
73
+ * Init-time only; throws on anything that is not a name or a non-empty
74
+ * array of names.
75
+ */
76
+ const resolveFields = function ( from ) {
77
+ if ( typeof from === 'string' ) {
78
+ if ( from === '' ) {
79
+ throw new Error( 'winkComposer/trackActivity: from must be a non-empty field name.' );
80
+ }
81
+ return [ from ];
82
+ }
83
+ if ( Array.isArray( from ) && ( from.length > 0 ) ) {
84
+ for ( let i = 0; i < from.length; i += 1 ) {
85
+ if ( ( typeof from[ i ] !== 'string' ) || ( from[ i ] === '' ) ) {
86
+ throw new Error( 'winkComposer/trackActivity: every from field must be a non-empty string.' );
87
+ }
88
+ }
89
+ return from.slice();
90
+ }
91
+ throw new Error( 'winkComposer/trackActivity: from must be a field name or a non-empty array of field names.' );
92
+ }; // resolveFields()
93
+
94
+ /**
95
+ * Resolve the `writes` option to the four output names (null = not stamped).
96
+ * Init-time only; rejects unknown keys and empty names.
97
+ */
98
+ const resolveWrites = function ( writes ) {
99
+ if ( ( typeof writes !== 'object' ) || ( writes === null ) || Array.isArray( writes ) ) {
100
+ throw new Error( 'winkComposer/trackActivity: writes must be an object naming at least one output.' );
101
+ }
102
+ const writeKeys = Object.keys( writes );
103
+ if ( writeKeys.length === 0 ) {
104
+ throw new Error( 'winkComposer/trackActivity: writes must name at least one of active, activeFor, sinceActivity, activeStart.' );
105
+ }
106
+ for ( let i = 0; i < writeKeys.length; i += 1 ) {
107
+ const key = writeKeys[ i ];
108
+ if ( !ALLOWED_WRITES.includes( key ) ) {
109
+ throw new Error( 'winkComposer/trackActivity: unknown write \'' + key + '\' (allowed: active, activeFor, sinceActivity, activeStart).' );
110
+ }
111
+ if ( ( typeof writes[ key ] !== 'string' ) || ( writes[ key ] === '' ) ) {
112
+ throw new Error( 'winkComposer/trackActivity: writes.' + key + ' must be a non-empty field name.' );
113
+ }
114
+ }
115
+ return {
116
+ active: ( writes.active === undefined ) ? null : writes.active,
117
+ activeFor: ( writes.activeFor === undefined ) ? null : writes.activeFor,
118
+ sinceActivity: ( writes.sinceActivity === undefined ) ? null : writes.sinceActivity,
119
+ activeStart: ( writes.activeStart === undefined ) ? null : writes.activeStart
120
+ };
121
+ }; // resolveWrites()
122
+
123
+ /**
124
+ * Build an in-place activity-state stamper.
125
+ *
126
+ * @param {Object} options
127
+ * @param {string|string[]} options.from - field(s) to watch for change. Required.
128
+ * @param {number} options.windowSec - hold "active" this long after the last
129
+ * activity (seconds). Required, positive.
130
+ * @param {number} [options.epsilon=0] - ignore a numeric change at or below this
131
+ * between consecutive messages (a noise gate; 0 = any change counts).
132
+ * @param {string} [options.timestampField='timestamp'] - epoch-ms clock field.
133
+ * @param {Object} options.writes - names the outputs to stamp; at least one of
134
+ * `active`, `activeFor`, `sinceActivity`, `activeStart`, each a non-empty field name.
135
+ * @returns {function( Object ): Object} transform( msg ) -> msg, mutated in place.
136
+ * A non-finite timestamp writes null to the configured outputs and does not
137
+ * advance the state.
138
+ */
139
+ const trackActivity = function ( options ) {
140
+ const opts = options || {};
141
+ const fields = resolveFields( opts.from );
142
+ const windowSec = finiteNumberOr( opts.windowSec, undefined, 'trackActivity', 'windowSec' );
143
+ if ( windowSec <= 0 ) {
144
+ throw new Error( 'winkComposer/trackActivity: windowSec must be a positive number.' );
145
+ }
146
+ const windowMs = windowSec * 1000;
147
+ const epsilon = finiteNumberOr( opts.epsilon, 0, 'trackActivity', 'epsilon' );
148
+ if ( epsilon < 0 ) {
149
+ throw new Error( 'winkComposer/trackActivity: epsilon must be a non-negative number.' );
150
+ }
151
+ const tsField = fieldNameOr( opts.timestampField, 'timestamp', 'trackActivity', 'timestampField' );
152
+ const w = resolveWrites( opts.writes );
153
+
154
+ // State captured at init — the hot path allocates nothing. prev is a private
155
+ // dictionary of last-seen values ( external field-name keys -> Object.create(null) ).
156
+ const n = fields.length;
157
+ const prev = Object.create( null );
158
+ let lastActivityTs = null;
159
+ let activeStartTs = null;
160
+ let wasActive = false;
161
+
162
+ return function ( msg ) {
163
+ const t = msg[ tsField ];
164
+ if ( !Number.isFinite( t ) ) {
165
+ if ( w.active !== null ) {
166
+ msg[ w.active ] = null;
167
+ }
168
+ if ( w.activeFor !== null ) {
169
+ msg[ w.activeFor ] = null;
170
+ }
171
+ if ( w.sinceActivity !== null ) {
172
+ msg[ w.sinceActivity ] = null;
173
+ }
174
+ if ( w.activeStart !== null ) {
175
+ msg[ w.activeStart ] = null;
176
+ }
177
+ return msg;
178
+ }
179
+
180
+ let activity = false;
181
+ for ( let i = 0; i < n; i += 1 ) {
182
+ const f = fields[ i ];
183
+ const cur = msg[ f ];
184
+ if ( !isNoValue( cur ) ) {
185
+ const p = prev[ f ];
186
+ if ( p === undefined ) {
187
+ prev[ f ] = cur; // first value for this field — seed, no change
188
+ } else {
189
+ if ( differs( cur, p, epsilon ) ) {
190
+ activity = true;
191
+ }
192
+ prev[ f ] = cur;
193
+ }
194
+ }
195
+ }
196
+
197
+ if ( activity ) {
198
+ lastActivityTs = t;
199
+ }
200
+ const active = ( lastActivityTs !== null ) && ( ( t - lastActivityTs ) <= windowMs );
201
+ if ( active && ( wasActive === false ) ) {
202
+ activeStartTs = t; // rising edge — the active run begins now
203
+ }
204
+ wasActive = active;
205
+
206
+ if ( w.active !== null ) {
207
+ msg[ w.active ] = active;
208
+ }
209
+ if ( w.activeFor !== null ) {
210
+ msg[ w.activeFor ] = active ? Math.max( 0, t - activeStartTs ) : 0;
211
+ }
212
+ if ( w.sinceActivity !== null ) {
213
+ msg[ w.sinceActivity ] = ( lastActivityTs === null ) ? null : Math.max( 0, t - lastActivityTs );
214
+ }
215
+ if ( w.activeStart !== null ) {
216
+ msg[ w.activeStart ] = activeStartTs;
217
+ }
218
+ return msg;
219
+ };
220
+ }; // trackActivity()
221
+
222
+ export { trackActivity };
223
+ export default trackActivity;
@@ -0,0 +1,76 @@
1
+ // src/tools/stream-prep/validate.js
2
+
3
+ /**
4
+ * @fileoverview Shared init-time validation helpers for the stream-preparation
5
+ * utilities. Nothing here runs on the hot path — every helper is called once,
6
+ * inside a factory, and throws immediately with the calling utility's name
7
+ * leading the message, so a bad config names its own site (fail-fast, the
8
+ * ADR-025 family contract; same error style as the training utilities).
9
+ */
10
+
11
+ const MINUTES_PER_DAY = 1440;
12
+
13
+ /**
14
+ * Resolve an optional field-name option: undefined takes the fallback; the
15
+ * result must be a non-empty string.
16
+ *
17
+ * @param {*} value - the option as given
18
+ * @param {string} fallback - default when the option is undefined
19
+ * @param {string} ctx - calling utility name, for the error message
20
+ * @param {string} name - option name, for the error message
21
+ * @returns {string} the validated field name
22
+ */
23
+ const fieldNameOr = function ( value, fallback, ctx, name ) {
24
+ const out = ( value === undefined ) ? fallback : value;
25
+ if ( ( typeof out !== 'string' ) || ( out === '' ) ) {
26
+ throw new Error( 'winkComposer/' + ctx + ': ' + name + ' must be a non-empty string.' );
27
+ }
28
+ return out;
29
+ }; // fieldNameOr()
30
+
31
+ /**
32
+ * Resolve an optional numeric option: undefined takes the fallback; the
33
+ * result must be a finite number.
34
+ *
35
+ * @param {*} value - the option as given
36
+ * @param {number} fallback - default when the option is undefined
37
+ * @param {string} ctx - calling utility name, for the error message
38
+ * @param {string} name - option name, for the error message
39
+ * @returns {number} the validated number
40
+ */
41
+ const finiteNumberOr = function ( value, fallback, ctx, name ) {
42
+ const out = ( value === undefined ) ? fallback : value;
43
+ if ( ( typeof out !== 'number' ) || !Number.isFinite( out ) ) {
44
+ throw new Error( 'winkComposer/' + ctx + ': ' + name + ' must be a finite number.' );
45
+ }
46
+ return out;
47
+ }; // finiteNumberOr()
48
+
49
+ /**
50
+ * Validate a shift schedule's boundary list: a non-empty array of strictly
51
+ * ascending minutes-of-day, each in [0, 1440). Returns a private copy so the
52
+ * caller's hot path cannot be disturbed by later mutation of the original.
53
+ *
54
+ * @param {*} boundariesMin - the option as given
55
+ * @param {string} ctx - calling utility name, for the error message
56
+ * @returns {number[]} a validated private copy
57
+ */
58
+ const validatedBoundaries = function ( boundariesMin, ctx ) {
59
+ if ( !Array.isArray( boundariesMin ) || ( boundariesMin.length === 0 ) ) {
60
+ throw new Error( 'winkComposer/' + ctx + ': boundariesMin must be a non-empty array.' );
61
+ }
62
+ let prev = -1;
63
+ for ( let i = 0; i < boundariesMin.length; i += 1 ) {
64
+ const b = boundariesMin[ i ];
65
+ if ( ( typeof b !== 'number' ) || !Number.isFinite( b ) || ( b < 0 ) || ( b >= MINUTES_PER_DAY ) ) {
66
+ throw new Error( 'winkComposer/' + ctx + ': each boundary must be a minute-of-day in [0, 1440).' );
67
+ }
68
+ if ( b <= prev ) {
69
+ throw new Error( 'winkComposer/' + ctx + ': boundariesMin must be strictly ascending.' );
70
+ }
71
+ prev = b;
72
+ }
73
+ return boundariesMin.slice();
74
+ }; // validatedBoundaries()
75
+
76
+ export { fieldNameOr, finiteNumberOr, validatedBoundaries };