@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 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.1",
4
4
  "description": "Composable Streaming Intelligence — turn streaming data into actionable insights, from edge to cloud",
5
5
  "keywords": [
6
6
  "streaming",
@@ -27,11 +27,10 @@
27
27
  },
28
28
  "scripts": {
29
29
  "pretest": "npm run lint",
30
- "test": "mocha --recursive 'src/**/*.specs.js' --ignore 'src/**/slow-*.specs.js'",
31
- "test:hardening": "node --expose-gc node_modules/.bin/mocha --recursive 'src/**/slow-*.specs.js' --timeout 300000 --exit",
32
- "test:coverage": "c8 --reporter=html mocha --recursive 'src/**/*.specs.js' --ignore 'src/**/slow-*.specs.js'",
33
- "lint": "eslint 'src/**/*.js'",
34
- "docs-serve": "docsify serve ./docs"
30
+ "test": "mocha --recursive \"src/**/*.specs.js\" --ignore \"src/**/slow-*.specs.js\"",
31
+ "test:hardening": "node --expose-gc node_modules/mocha/bin/mocha.js --recursive \"src/**/slow-*.specs.js\" --timeout 300000 --exit",
32
+ "test:coverage": "c8 --reporter=html mocha --recursive \"src/**/*.specs.js\" --ignore \"src/**/slow-*.specs.js\"",
33
+ "lint": "eslint \"src/**/*.js\""
35
34
  },
36
35
  "repository": {
37
36
  "type": "git",
@@ -51,7 +50,8 @@
51
50
  "src/tools/**/*.js",
52
51
  "src/windowing/**/*.js",
53
52
  "!src/**/*.specs.js",
54
- "!src/**/test/"
53
+ "!src/**/test/",
54
+ "!src/core/emitter-manager/mqtt/mqtt-store.js"
55
55
  ],
56
56
  "bugs": {
57
57
  "url": "https://github.com/winkjs/composer/issues"
@@ -63,6 +63,7 @@
63
63
  "devDependencies": {
64
64
  "c8": "^10.1.3",
65
65
  "chai": "^5.2.0",
66
+ "classic-level": "^3.0.0",
66
67
  "eslint": "^9.21.0",
67
68
  "eslint-plugin-security": "^4.0.0",
68
69
  "mocha": "^11.1.0",
@@ -70,7 +71,6 @@
70
71
  },
71
72
  "dependencies": {
72
73
  "@questdb/nodejs-client": "^4.2.0",
73
- "classic-level": "^3.0.0",
74
74
  "glob": "^13.0.6",
75
75
  "mqtt": "^5.15.1",
76
76
  "msgpackr": "^1.11.10",
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
+
@@ -41,7 +41,8 @@
41
41
  * ├── index.js # Module surface: id, schema, factory
42
42
  * ├── emitter.js # The emitter implementation
43
43
  * ├── mqtt-store.js # DORMANT LevelDB store (ADR-021; kept
44
- * │ # for the WAL successor, not wired)
44
+ * │ # for the WAL successor, not wired,
45
+ * │ # not shipped in the npm package)
45
46
  * ├── constants.js # Shared configuration
46
47
  * └── test/ # Spec files
47
48
  *
@@ -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;