@winkjs/composer 0.4.2 → 0.5.1
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 +5 -1
- package/package.json +8 -8
- package/src/composer.js +9 -0
- package/src/core/emitter-manager/mqtt/index.js +2 -1
- package/src/tools/stream-prep/coerce-numeric.js +94 -0
- package/src/tools/stream-prep/filter-rows.js +96 -0
- package/src/tools/stream-prep/label-shift.js +112 -0
- package/src/tools/stream-prep/normalize-timestamp.js +265 -0
- package/src/tools/stream-prep/stamp-period.js +111 -0
- package/src/tools/stream-prep/track-activity.js +223 -0
- package/src/tools/stream-prep/validate.js +76 -0
- package/src/core/emitter-manager/mqtt/mqtt-store.js +0 -538
|
@@ -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 };
|