@infitx/match 0.0.1 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +765 -0
  3. package/index.js +167 -0
  4. package/package.json +21 -8
package/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ ## [1.3.2](https://github.com/infitx-org/release-cd/compare/match-v1.3.1...match-v1.3.2) (2026-02-09)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * update package versions and permissions in workflows ([c582d9f](https://github.com/infitx-org/release-cd/commit/c582d9faff24e6a0549e529608f0ef6a8b362bcf))
9
+
10
+ ## [1.3.1](https://github.com/infitx-org/release-cd/compare/match-v1.3.0...match-v1.3.1) (2026-02-09)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * deploy ([9aa2bd6](https://github.com/infitx-org/release-cd/commit/9aa2bd69093d4374f86dd8554a527b627032c326))
16
+
17
+ ## [1.3.0](https://github.com/infitx-org/release-cd/compare/match-v1.2.1...match-v1.3.0) (2026-02-09)
18
+
19
+
20
+ ### Features
21
+
22
+ * add .npmignore files and ci-publish script for decision and match libraries ([c518708](https://github.com/infitx-org/release-cd/commit/c5187080c63a215a670fcbe37a11989d6ec4c37f))
23
+
24
+ ## [1.2.1](https://github.com/infitx-org/release-cd/compare/match-v1.2.0...match-v1.2.1) (2026-02-04)
25
+
26
+
27
+ ### Bug Fixes
28
+
29
+ * enhance match function to support reference time and update test cases ([deb24d0](https://github.com/infitx-org/release-cd/commit/deb24d078f82fdc358b43bf2aa174d537b169c1d))
30
+
31
+ ## [1.2.0](https://github.com/infitx-org/release-cd/compare/match-v1.1.1...match-v1.2.0) (2026-01-15)
32
+
33
+
34
+ ### Features
35
+
36
+ * add negation support with 'not' property in match function ([31fddc6](https://github.com/infitx-org/release-cd/commit/31fddc6ae4e7661bf71e37e40490e6baf0ce38aa))
37
+ * add support for Grafana-style time intervals in match function ([ef622bb](https://github.com/infitx-org/release-cd/commit/ef622bba120978b3cf5950a08d34dd5e47dbbea5))
38
+
39
+ ## [1.1.1](https://github.com/infitx-org/release-cd/compare/match-v1.1.0...match-v1.1.1) (2025-12-26)
40
+
41
+
42
+ ### Bug Fixes
43
+
44
+ * improve match function for nullish values ([2c5a5c0](https://github.com/infitx-org/release-cd/commit/2c5a5c05ef34ddb013a610ba4357f34185e60eed))
45
+
46
+ ## [1.1.0](https://github.com/infitx-org/release-cd/compare/match-v1.0.0...match-v1.1.0) (2025-12-23)
47
+
48
+
49
+ ### Features
50
+
51
+ * add build script to package.json for decision and match libraries ([f4da999](https://github.com/infitx-org/release-cd/commit/f4da9993feec346cc94f573fa103dffa8c4a9eed))
52
+ * match and decision libraries ([db62341](https://github.com/infitx-org/release-cd/commit/db623419a179b3e0ec0cbda05b2f135e01375552))
package/README.md ADDED
@@ -0,0 +1,765 @@
1
+ # Match Function
2
+
3
+ A flexible matching utility that compares values with advanced semantic rules,
4
+ supporting nested structures, arrays, type coercion, and various matching strategies.
5
+
6
+ ## Installation
7
+
8
+ ```javascript
9
+ const match = require('./match');
10
+ ```
11
+
12
+ ## Basic Usage
13
+
14
+ ```javascript
15
+ const match = require('./match');
16
+
17
+ // Exact match
18
+ match({ a: 1 }, { a: 1 }); // true
19
+ match({ a: 1 }, { a: 2 }); // false
20
+
21
+ // Partial object match
22
+ match({ a: 1, b: 2 }, { a: 1 }); // true
23
+ ```
24
+
25
+ ## Matching Against Nested Structures
26
+
27
+ The match function recursively compares nested objects:
28
+
29
+ ```javascript
30
+ // Nested object matching
31
+ match(
32
+ { user: { name: 'Alice', age: 30 } },
33
+ { user: { name: 'Alice' } }
34
+ ); // true - partial match on nested object
35
+
36
+ match(
37
+ { user: { name: 'Alice', age: 30 } },
38
+ { user: { name: 'Bob' } }
39
+ ); // false - name doesn't match
40
+
41
+ // Deep nesting
42
+ match(
43
+ { a: { b: { c: { d: 'value' } } } },
44
+ { a: { b: { c: { d: 'value' } } } }
45
+ ); // true
46
+
47
+ match(
48
+ { a: { b: { c: { d: 'value', e: 'extra' } } } },
49
+ { a: { b: { c: { d: 'value' } } } }
50
+ ); // true - extra properties are ignored
51
+ ```
52
+
53
+ ## Array Matching (Any-Of Semantics)
54
+
55
+ Arrays implement "any of" semantics - at least one element must match. The
56
+ behavior varies depending on whether arrays appear in the value, the condition,
57
+ or both.
58
+
59
+ ### Array in Condition (Rule)
60
+
61
+ When the condition is an array, the value must match **at least one** element
62
+ in the array:
63
+
64
+ ```javascript
65
+ // Scalar value against array condition
66
+ match('hello', ['hello', 'world']); // true - matches first element
67
+ match('world', ['hello', 'world']); // true - matches second element
68
+ match('goodbye', ['hello', 'world']); // false - matches none
69
+
70
+ // With numbers
71
+ match(5, [1, 5, 10]); // true
72
+ match(7, [1, 5, 10]); // false
73
+
74
+ // With booleans
75
+ match(true, [true, false]); // true
76
+ match(false, [true]); // false
77
+
78
+ // In nested structures - scalar value against array condition
79
+ match(
80
+ { status: 'active' },
81
+ { status: ['active', 'pending', 'processing'] }
82
+ ); // true - status matches one of the allowed values
83
+
84
+ match(
85
+ { status: 'inactive' },
86
+ { status: ['active', 'pending'] }
87
+ ); // false - status doesn't match any allowed value
88
+ ```
89
+
90
+ ### Array in Value
91
+
92
+ When the value is an array, **at least one element** must match the condition:
93
+
94
+ ```javascript
95
+ // Array value against scalar condition
96
+ match(['apple', 'banana'], 'apple'); // true - first element matches
97
+ match(['apple', 'banana'], 'banana'); // true - second element matches
98
+ match(['apple', 'banana'], 'orange'); // false - no element matches
99
+
100
+ // With numbers
101
+ match([1, 2, 3], 2); // true
102
+ match([1, 2, 3], 5); // false
103
+
104
+ // In nested structures - array value against scalar condition
105
+ match(
106
+ { tags: ['javascript', 'node', 'async'] },
107
+ { tags: 'javascript' }
108
+ ); // true - at least one tag matches
109
+
110
+ match(
111
+ { tags: ['python', 'django'] },
112
+ { tags: 'javascript' }
113
+ ); // false - no tag matches
114
+
115
+ // Array value against function condition
116
+ match(
117
+ { scores: [85, 90, 78] },
118
+ { scores: (score) => score >= 80 }
119
+ ); // true - at least one score is >= 80
120
+
121
+ match(
122
+ { scores: [65, 70, 75] },
123
+ { scores: (score) => score >= 80 }
124
+ ); // false - no score is >= 80
125
+ ```
126
+
127
+ ### Array in Both Value and Condition
128
+
129
+ When both are arrays, a match occurs if **any value element matches any
130
+ condition element**:
131
+
132
+ ```javascript
133
+ // Both arrays - cartesian "any of any" matching
134
+ match(['red', 'blue'], ['blue', 'green']); // true - 'blue' appears in both
135
+ match(['red', 'yellow'], ['blue', 'green']); // false - no common elements
136
+
137
+ match([1, 2, 3], [3, 4, 5]); // true - '3' is in both
138
+ match([1, 2], [3, 4]); // false - no overlap
139
+
140
+ // In nested structures
141
+ match(
142
+ { tags: ['javascript', 'node'] },
143
+ { tags: ['node', 'async', 'backend'] }
144
+ ); // true - 'node' is in both arrays
145
+
146
+ match(
147
+ { tags: ['python', 'django'] },
148
+ { tags: ['javascript', 'react'] }
149
+ ); // false - no common tags
150
+
151
+ // Complex: array value against array of conditions (including objects)
152
+ match(
153
+ { priority: [1, 2, 3] },
154
+ { priority: [{ min: 2, max: 5 }, 10] }
155
+ ); // true - elements 2 and 3 match the range { min: 2, max: 5 }
156
+
157
+ match(
158
+ { priority: [1] },
159
+ { priority: [{ min: 2, max: 5 }, 10] }
160
+ ); // false - 1 doesn't match range or 10
161
+ ```
162
+
163
+ ### Array Value Against Object Condition
164
+
165
+ When an array value is matched against an object condition (like a range or
166
+ complex object), each element is tested against the condition:
167
+
168
+ ```javascript
169
+ // Array value against range condition
170
+ match(
171
+ [1, 5, 10],
172
+ { min: 3, max: 7 }
173
+ ); // true - element '5' falls within the range
174
+
175
+ match(
176
+ [1, 2],
177
+ { min: 3, max: 7 }
178
+ ); // false - no element falls within the range
179
+
180
+ // In nested structures
181
+ match(
182
+ { scores: [45, 67, 89, 92] },
183
+ { scores: { min: 80 } }
184
+ ); // true - elements 89 and 92 are >= 80
185
+
186
+ match(
187
+ { scores: [45, 67, 75] },
188
+ { scores: { min: 80 } }
189
+ ); // false - no score is >= 80
190
+
191
+ // Array value against regex condition
192
+ match(
193
+ { emails: ['admin@test.com', 'invalid', 'user@test.com'] },
194
+ { emails: /@test\.com$/ }
195
+ ); // true - at least one email matches the pattern
196
+
197
+ match(
198
+ { emails: ['invalid1', 'invalid2'] },
199
+ { emails: /@test\.com$/ }
200
+ ); // false - no email matches
201
+ ```
202
+
203
+ ### Combining Arrays with Null
204
+
205
+ Arrays can include `null` to make conditions optional:
206
+
207
+ ```javascript
208
+ // Array with null - matches if property is missing OR matches other values
209
+ match(
210
+ { status: 'active' },
211
+ { status: [null, 'active'] }
212
+ ); // true - matches 'active'
213
+
214
+ match(
215
+ { name: 'Alice' },
216
+ { status: [null, 'active'] }
217
+ ); // true - 'status' is missing, matches null
218
+
219
+ match(
220
+ { status: false },
221
+ { status: [null, 'active'] }
222
+ ); // false - false is neither null nor 'active'
223
+
224
+ match(
225
+ { status: undefined },
226
+ { status: [null, 'active'] }
227
+ ); // true - undefined treated as null
228
+
229
+ // Nested with arrays and null
230
+ match(
231
+ { user: { role: 'admin' } },
232
+ { user: { role: [null, 'admin', 'moderator'] } }
233
+ ); // true - role matches 'admin'
234
+
235
+ match(
236
+ { user: { name: 'Alice' } },
237
+ { user: { role: [null, 'admin'], name: 'Alice' } }
238
+ ); // true - role is missing (matches null), name matches
239
+ ```
240
+
241
+ ### Array Behavior Summary
242
+
243
+ | Value Type | Condition Type | Behavior |
244
+ | ---------- | -------------------------- | --------------------------------------------- |
245
+ | Scalar | Array | Value must match at least one array element |
246
+ | Array | Scalar | At least one value must match the scalar |
247
+ | Array | Array | At least one value must match one condition |
248
+ | Array | Object (range/complex) | At least one value must match the condition |
249
+ | Array | Function | At least one value must satisfy the function |
250
+
251
+ ## Matching Against Non-Existing Properties
252
+
253
+ The match function has special handling for `null` values to match against
254
+ non-existing properties:
255
+
256
+ ```javascript
257
+ // null matches undefined/missing properties
258
+ match({ b: 0 }, { a: null }); // true - 'a' is missing
259
+
260
+ match({ a: null }, { a: null }); // true - both null
261
+
262
+ match({ a: undefined }, { a: null }); // true - undefined treated as null
263
+
264
+ match({ a: false }, { a: null }); // false - false is not null
265
+
266
+ match({ a: 0 }, { a: null }); // false - 0 is not null
267
+
268
+ match({ a: '' }, { a: null }); // false - empty string is not null
269
+
270
+ // Nested non-existing properties
271
+ match(
272
+ { a: {} },
273
+ { a: { b: null } }
274
+ ); // true - 'b' doesn't exist in nested object
275
+
276
+ match(
277
+ { a: { b: false } },
278
+ { a: { b: null } }
279
+ ); // false - 'b' exists and is false
280
+
281
+ // Array with null for optional properties
282
+ match(
283
+ { status: 'active' },
284
+ { status: [null, 'active'] }
285
+ ); // true - matches 'active'
286
+
287
+ match(
288
+ { name: 'Alice' },
289
+ { status: [null, 'active'] }
290
+ ); // true - 'status' is missing, matches null
291
+
292
+ match(
293
+ { status: false },
294
+ { status: [null, 'active'] }
295
+ ); // false - false doesn't match null or 'active'
296
+ ```
297
+
298
+ ## Type Coercion
299
+
300
+ The function coerces values to match the type of the rule:
301
+
302
+ ```javascript
303
+ // Boolean coercion
304
+ match('hello', true); // true - truthy string
305
+ match('', false); // true - falsy empty string
306
+ match(0, false); // true - falsy zero
307
+ match(1, true); // true - truthy number
308
+
309
+ // String coercion
310
+ match(123, '123'); // true
311
+ match(true, 'true'); // true
312
+
313
+ // Number coercion
314
+ match('42', 42); // true
315
+ match('3.14', 3.14); // true
316
+ ```
317
+
318
+ ## Range Matching
319
+
320
+ Use `min` and `max` for numeric and date range matching:
321
+
322
+ ```javascript
323
+ // Numeric ranges
324
+ match(5, { min: 1, max: 10 }); // true
325
+ match(15, { min: 1, max: 10 }); // false
326
+ match(1, { min: 1 }); // true - only minimum
327
+ match(10, { max: 10 }); // true - only maximum
328
+
329
+ // Date ranges
330
+ const now = new Date('2025-06-15');
331
+ const start = new Date('2025-01-01');
332
+ const end = new Date('2025-12-31');
333
+
334
+ match(now, { min: start, max: end }); // true
335
+ match(new Date('2026-01-01'), { min: start, max: end }); // false
336
+
337
+ // Nested range matching
338
+ match(
339
+ { user: { age: 25 } },
340
+ { user: { age: { min: 18, max: 65 } } }
341
+ ); // true
342
+ ```
343
+
344
+ ### Grafana-Style Time Intervals
345
+
346
+ The `min` and `max` properties support Grafana-style relative time intervals
347
+ for convenient time-based matching:
348
+
349
+ ```javascript
350
+ // Current time
351
+ match(new Date(), { min: 'now-1h', max: 'now' });
352
+ // true - current time is within the last hour
353
+
354
+ // Future time check
355
+ match(new Date(), { max: 'now+1d' });
356
+ // true - current time is before tomorrow
357
+
358
+ // Past time check
359
+ match(new Date(), { min: 'now-1w' });
360
+ // true - current time is within the last week
361
+
362
+ // Time range
363
+ match(
364
+ new Date(),
365
+ { min: 'now-1d', max: 'now+1d' }
366
+ ); // true - current time is within ±1 day
367
+
368
+ // In nested structures
369
+ match(
370
+ { event: { timestamp: new Date() } },
371
+ { event: { timestamp: { min: 'now-5m' } } }
372
+ ); // true - event occurred within the last 5 minutes
373
+
374
+ match(
375
+ { event: { timestamp: new Date(Date.now() - 10 * 60 * 1000) } },
376
+ { event: { timestamp: { min: 'now-5m' } } }
377
+ ); // false - event occurred more than 5 minutes ago
378
+ ```
379
+
380
+ #### Supported Time Units
381
+
382
+ | Unit | Description | Example |
383
+ | ---- | -------------- | ----------- |
384
+ | `ms` | Milliseconds | `now-500ms` |
385
+ | `s` | Seconds | `now-30s` |
386
+ | `m` | Minutes | `now-5m` |
387
+ | `h` | Hours | `now-2h` |
388
+ | `d` | Days | `now-7d` |
389
+ | `w` | Weeks | `now-2w` |
390
+ | `M` | Months (30d) | `now-3M` |
391
+ | `y` | Years (365d) | `now-1y` |
392
+
393
+ #### Rounding Units
394
+
395
+ When using the `/` operator, you can round to these time units:
396
+
397
+ | Unit | Rounds To | Example |
398
+ | ---- | -------------------- | -------------------------------- |
399
+ | `s` | Start of second | `now/s` = current second at .000 |
400
+ | `m` | Start of minute | `now/m` = current minute at :00 |
401
+ | `h` | Start of hour | `now/h` = current hour at :00:00 |
402
+ | `d` | Start of day | `now/d` = today at 00:00:00 |
403
+ | `w` | Start of week | `now/w` = Monday at 00:00:00 |
404
+ | `M` | Start of month | `now/M` = 1st of month 00:00:00 |
405
+ | `y` | Start of year | `now/y` = Jan 1st at 00:00:00 |
406
+
407
+ **Note**: Week rounding always rounds to Monday as the first day of the week.
408
+
409
+ #### Time Interval Format
410
+
411
+ - **`now`**: Current time
412
+ - **`now-<amount><unit>`**: Time in the past (e.g., `now-5m` = 5 minutes ago)
413
+ - **`now+<amount><unit>`**: Time in the future (e.g., `now+1h` = 1 hour from now)
414
+ - **`now/<unit>`**: Current time rounded to start of unit (e.g., `now/d` = start of today)
415
+ - **`now[+-]<amount><unit>/<roundUnit>`**: Time with offset, rounded (e.g., `now-5d/d` = 5 days ago at midnight)
416
+
417
+ #### Time Rounding
418
+
419
+ The `/` operator rounds timestamps to the start of the specified time unit, following Grafana's approach:
420
+
421
+ ```javascript
422
+ // Round to start of current day (midnight)
423
+ match(new Date('2025-06-15T14:30:00'), { min: 'now/d', max: 'now' });
424
+ // Compares against start of current day
425
+
426
+ // Round to start of current hour
427
+ match(new Date(), { min: 'now/h' });
428
+ // Matches times from the start of the current hour
429
+
430
+ // Combine offset and rounding
431
+ // Example: 5 days ago, rounded to midnight of that day
432
+ match(new Date(), { min: 'now-5d/d' });
433
+
434
+ // Week rounding (rounds to Monday)
435
+ match(new Date(), { min: 'now/w' });
436
+ // Matches times from the start of the current week
437
+
438
+ // Month rounding
439
+ match(new Date(), { min: 'now-1M/M' });
440
+ // Matches times from the start of last month
441
+
442
+ // Rounding units: s (second), m (minute), h (hour), d (day),
443
+ // w (week), M (month), y (year)
444
+ ```
445
+
446
+ ```javascript
447
+ // Examples with different units
448
+ match(new Date(), { min: 'now-500ms' }); // Last 500 milliseconds
449
+ match(new Date(), { min: 'now-30s' }); // Last 30 seconds
450
+ match(new Date(), { min: 'now-5m' }); // Last 5 minutes
451
+ match(new Date(), { min: 'now-2h' }); // Last 2 hours
452
+ match(new Date(), { min: 'now-7d' }); // Last 7 days
453
+ match(new Date(), { min: 'now-2w' }); // Last 2 weeks
454
+ match(new Date(), { min: 'now-3M' }); // Last 3 months (approx)
455
+ match(new Date(), { min: 'now-1y' }); // Last year (approx)
456
+
457
+ // Future times
458
+ match(new Date(), { max: 'now+1h' }); // Within next hour
459
+ match(new Date(), { max: 'now+7d' }); // Within next 7 days
460
+
461
+ // Examples with rounding
462
+ match(new Date(), { min: 'now/d' }); // Since midnight today
463
+ match(new Date(), { min: 'now-7d/d', max: 'now/d' }); // Last 7 full days
464
+ match(new Date(), { min: 'now/M' }); // Since start of current month
465
+ match(new Date(), { min: 'now-1y/y', max: 'now/y' }); // Last full year
466
+
467
+ // Practical examples
468
+ // Check if log entry is recent (last 15 minutes)
469
+ match(
470
+ { log: { timestamp: new Date() } },
471
+ { log: { timestamp: { min: 'now-15m' } } }
472
+ ); // true
473
+
474
+ // Check if event occurred today (since midnight)
475
+ match(
476
+ { event: { timestamp: new Date() } },
477
+ { event: { timestamp: { min: 'now/d' } } }
478
+ ); // true if event is from today
479
+
480
+ // Check if data is from the current month
481
+ match(
482
+ { report: { date: new Date() } },
483
+ { report: { date: { min: 'now/M', max: 'now' } } }
484
+ ); // true if report is from current month
485
+
486
+ // Check if scheduled event is upcoming (next 24 hours)
487
+ match(
488
+ { event: { scheduledAt: new Date(Date.now() + 12 * 60 * 60 * 1000) } },
489
+ { event: { scheduledAt: { min: 'now', max: 'now+1d' } } }
490
+ ); // true
491
+
492
+ // Check if user session is still valid (created within last 30 minutes)
493
+ match(
494
+ { session: { createdAt: new Date(Date.now() - 10 * 60 * 1000) } },
495
+ { session: { createdAt: { min: 'now-30m' } } }
496
+ ); // true
497
+
498
+ // Daily reports: match events from start of day until now
499
+ match(
500
+ { log: { timestamp: new Date() } },
501
+ { log: { timestamp: { min: 'now/d', max: 'now' } } }
502
+ ); // Matches anything that happened today
503
+
504
+ // Weekly reports: last 7 complete days
505
+ match(
506
+ { metric: { recorded: new Date() } },
507
+ { metric: { recorded: { min: 'now-7d/d', max: 'now/d' } } }
508
+ ); // Matches data from last 7 full days (midnight to midnight)
509
+
510
+ // Business hours check: events during current hour
511
+ match(
512
+ { transaction: { timestamp: new Date() } },
513
+ { transaction: { timestamp: { min: 'now/h', max: 'now' } } }
514
+ ); // Matches transactions from start of current hour
515
+ ```
516
+
517
+ ## Function Predicates
518
+
519
+ Use functions for custom matching logic:
520
+
521
+ ```javascript
522
+ // Function as rule
523
+ match(10, (value) => value > 5); // true
524
+ match(3, (value) => value > 5); // false
525
+
526
+ // Complex predicate
527
+ match(
528
+ 'hello@example.com',
529
+ (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
530
+ ); // true - valid email
531
+
532
+ // Function in nested structure
533
+ match(
534
+ { user: { age: 25 } },
535
+ { user: { age: (age) => age >= 18 } }
536
+ ); // true
537
+ ```
538
+
539
+ ## Negation with `not`
540
+
541
+ Use the `not` property in an object condition to negate any match:
542
+
543
+ ```javascript
544
+ // Basic negation
545
+ match(3, { not: 5 }); // true - 3 is not 5
546
+ match(5, { not: 5 }); // false - 5 is 5
547
+
548
+ // Negate string match
549
+ match('goodbye', { not: 'hello' }); // true
550
+ match('hello', { not: 'hello' }); // false
551
+
552
+ // Negate boolean
553
+ match(false, { not: true }); // true
554
+ match(true, { not: true }); // false
555
+ match(0, { not: true }); // true - 0 is falsy, not true
556
+ match(1, { not: true }); // false - 1 coerces to true
557
+
558
+ // Negate null
559
+ match(0, { not: null }); // true - 0 is not null
560
+ match(null, { not: null }); // false
561
+ match(undefined, { not: null }); // false - undefined treated as null
562
+
563
+ // In nested structures
564
+ match(
565
+ { status: 'inactive' },
566
+ { status: { not: 'active' } }
567
+ ); // true
568
+
569
+ match(
570
+ { status: 'active' },
571
+ { status: { not: 'active' } }
572
+ ); // false
573
+ ```
574
+
575
+ ### Negating Complex Conditions
576
+
577
+ The `not` property works with all match types including arrays, ranges,
578
+ functions, and regex:
579
+
580
+ ```javascript
581
+ // Negate regex
582
+ match('goodbye', { not: /hello/ }); // true
583
+ match('hello world', { not: /hello/ }); // false
584
+
585
+ match(
586
+ { email: 'user@domain.org' },
587
+ { email: { not: /@example\.com$/ } }
588
+ ); // true - doesn't end with @example.com
589
+
590
+ // Negate array (none of)
591
+ match(5, { not: [1, 2, 3] }); // true - 5 is not in the list
592
+ match(2, { not: [1, 2, 3] }); // false - 2 is in the list
593
+
594
+ match(
595
+ { role: 'guest' },
596
+ { role: { not: ['admin', 'moderator'] } }
597
+ ); // true - guest is neither admin nor moderator
598
+
599
+ match(
600
+ { role: 'admin' },
601
+ { role: { not: ['admin', 'moderator'] } }
602
+ ); // false - admin is in the list
603
+
604
+ // Negate range
605
+ match(3, { not: { min: 5, max: 10 } }); // true - 3 is outside the range
606
+ match(7, { not: { min: 5, max: 10 } }); // false - 7 is in the range
607
+ match(15, { not: { min: 5, max: 10 } }); // true - 15 is outside the range
608
+
609
+ match(
610
+ { age: 15 },
611
+ { age: { not: { min: 18, max: 65 } } }
612
+ ); // true - age is below minimum
613
+
614
+ match(
615
+ { age: 25 },
616
+ { age: { not: { min: 18, max: 65 } } }
617
+ ); // false - age is in range
618
+
619
+ // Negate function predicate
620
+ match(3, { not: (v) => v > 5 }); // true - 3 is not > 5
621
+ match(10, { not: (v) => v > 5 }); // false - 10 is > 5
622
+
623
+ match(
624
+ { price: 25 },
625
+ { price: { not: (p) => p >= 100 } }
626
+ ); // true - price is not >= 100
627
+
628
+ // Negate object match
629
+ match(
630
+ { user: { role: 'guest' } },
631
+ { user: { not: { role: 'admin' } } }
632
+ ); // true - role is not admin
633
+
634
+ match(
635
+ { user: { role: 'admin' } },
636
+ { user: { not: { role: 'admin' } } }
637
+ ); // false - role is admin
638
+
639
+ // Complex negation in nested structures
640
+ match(
641
+ {
642
+ user: {
643
+ email: 'user@custom.com',
644
+ role: 'user'
645
+ }
646
+ },
647
+ {
648
+ user: {
649
+ email: { not: /@example\.com$/ },
650
+ role: { not: ['admin', 'moderator'] }
651
+ }
652
+ }
653
+ ); // true - email doesn't end with @example.com and role is not admin/moderator
654
+ ```
655
+
656
+ ### Combining `not` with Other Conditions
657
+
658
+ You can combine `not` with other conditions in complex matching scenarios:
659
+
660
+ ```javascript
661
+ // Exclude certain values while checking other properties
662
+ match(
663
+ { status: 'pending', priority: 2 },
664
+ {
665
+ status: { not: ['cancelled', 'completed'] },
666
+ priority: { min: 1, max: 3 }
667
+ }
668
+ ); // true - status is not cancelled/completed and priority is in range
669
+
670
+ // Array values with negation
671
+ match(
672
+ { tags: ['javascript', 'backend'] },
673
+ { tags: { not: 'frontend' } }
674
+ ); // true - none of the tags are 'frontend'
675
+
676
+ match(
677
+ { tags: ['javascript', 'frontend'] },
678
+ { tags: { not: 'frontend' } }
679
+ ); // false - 'frontend' is in the tags
680
+ ```
681
+
682
+ ## Regular Expression Matching
683
+
684
+ ```javascript
685
+ // Regex patterns
686
+ match('hello world', /hello/); // true
687
+ match('goodbye world', /hello/); // false
688
+
689
+ // Case-insensitive matching
690
+ match('Hello World', /hello/i); // true
691
+
692
+ // Nested regex
693
+ match(
694
+ { email: 'user@example.com' },
695
+ { email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
696
+ ); // true
697
+ ```
698
+
699
+ ## Complex Examples
700
+
701
+ Combining multiple matching strategies:
702
+
703
+ ```javascript
704
+ // Complex nested structure with arrays and nulls
705
+ match(
706
+ {
707
+ user: {
708
+ name: 'Alice',
709
+ email: 'alice@example.com',
710
+ roles: ['admin', 'user']
711
+ },
712
+ status: 'active'
713
+ },
714
+ {
715
+ user: {
716
+ email: /@example\.com$/,
717
+ roles: 'admin'
718
+ },
719
+ status: ['active', 'pending'],
720
+ lastLogin: null // optional field
721
+ }
722
+ ); // true
723
+
724
+ // Multiple conditions
725
+ match(
726
+ { price: 50, category: 'electronics', inStock: true },
727
+ {
728
+ price: { min: 0, max: 100 },
729
+ category: ['electronics', 'computers'],
730
+ inStock: true,
731
+ discount: null // discount is optional
732
+ }
733
+ ); // true
734
+ ```
735
+
736
+ ## API
737
+
738
+ ### `match(factValue, ruleValue)`
739
+
740
+ Compares a fact value against a rule value with flexible matching semantics.
741
+
742
+ **Parameters:**
743
+
744
+ - `factValue` (any): The actual value to test
745
+ - `ruleValue` (any): The pattern/rule to match against
746
+
747
+ **Returns:**
748
+
749
+ - `boolean`: `true` if the fact matches the rule, `false` otherwise
750
+
751
+ **Matching Rules:**
752
+
753
+ - **Exact equality**: Returns `true` if values are strictly equal
754
+ - **Null handling**: `null` in rule matches `null` or `undefined` in fact
755
+ - **Arrays**: "Any of" semantics - at least one element must match
756
+ - **Objects**: Recursively matches properties (partial matching allowed)
757
+ - **Type coercion**: Values are coerced to match rule type
758
+ - **Ranges**: Objects with `min`/`max` properties enable range matching
759
+ - **Negation**: Objects with `not` property negate any match condition
760
+ - **Functions**: Rule functions are called with fact value as predicate
761
+ - **RegExp**: Tests string values against regex patterns
762
+
763
+ ## License
764
+
765
+ See the main project license.
package/index.js ADDED
@@ -0,0 +1,167 @@
1
+ const isMatchWith = require('lodash/isMatchWith');
2
+ const isPlainObject = require('lodash/isPlainObject');
3
+
4
+ // Round a date to the start of a time unit
5
+ function roundToUnit(date, unit) {
6
+ const d = new Date(date);
7
+
8
+ switch (unit) {
9
+ case 's': // Round to start of second
10
+ d.setMilliseconds(0);
11
+ break;
12
+ case 'm': // Round to start of minute
13
+ d.setSeconds(0, 0);
14
+ break;
15
+ case 'h': // Round to start of hour
16
+ d.setMinutes(0, 0, 0);
17
+ break;
18
+ case 'd': // Round to start of day
19
+ d.setHours(0, 0, 0, 0);
20
+ break;
21
+ case 'w': // Round to start of week (Monday)
22
+ d.setHours(0, 0, 0, 0);
23
+ const day = d.getDay();
24
+ const diff = day === 0 ? 6 : day - 1; // Monday is 1, Sunday is 0
25
+ d.setDate(d.getDate() - diff);
26
+ break;
27
+ case 'M': // Round to start of month
28
+ d.setDate(1);
29
+ d.setHours(0, 0, 0, 0);
30
+ break;
31
+ case 'y': // Round to start of year
32
+ d.setMonth(0, 1);
33
+ d.setHours(0, 0, 0, 0);
34
+ break;
35
+ }
36
+
37
+ return d;
38
+ }
39
+
40
+ // Parse Grafana-style time intervals (now, now-5m, now+1h, now/d, now-5d/d, etc.)
41
+ function parseTimeInterval(str, referenceTime) {
42
+ if (typeof str !== 'string') return null;
43
+
44
+ // Handle "now" without modifiers
45
+ if (str === 'now') return new Date(referenceTime);
46
+
47
+ // Handle "now/unit" (rounding without offset)
48
+ const roundOnlyMatch = /^now\/([smhdwMy])$/.exec(str);
49
+ if (roundOnlyMatch) {
50
+ const [, unit] = roundOnlyMatch;
51
+ return roundToUnit(new Date(referenceTime), unit);
52
+ }
53
+
54
+ // Handle "now[+-]amount(unit)" or "now[+-]amount(unit)/roundUnit"
55
+ const match = /^now([+-])(\d+)(ms|s|m|h|d|w|M|y)(?:\/([smhdwMy]))?$/.exec(str);
56
+ if (!match) return null;
57
+
58
+ const [, sign, amount, unit, roundUnit] = match;
59
+ const value = parseInt(amount, 10);
60
+ const multiplier = sign === '+' ? 1 : -1;
61
+
62
+ // Convert to milliseconds
63
+ const unitMultipliers = {
64
+ ms: 1,
65
+ s: 1000,
66
+ m: 60 * 1000,
67
+ h: 60 * 60 * 1000,
68
+ d: 24 * 60 * 60 * 1000,
69
+ w: 7 * 24 * 60 * 60 * 1000,
70
+ M: 30 * 24 * 60 * 60 * 1000, // Approximate month
71
+ y: 365 * 24 * 60 * 60 * 1000 // Approximate year
72
+ };
73
+
74
+ const offset = multiplier * value * unitMultipliers[unit];
75
+ console.log({ referenceTime, offset });
76
+ const resultTime = new Date(referenceTime + offset);
77
+
78
+ // Apply rounding if specified
79
+ if (roundUnit) {
80
+ return roundToUnit(resultTime, roundUnit);
81
+ }
82
+
83
+ return resultTime;
84
+ }
85
+
86
+ const match = (referenceTime) => (value, condition) => {
87
+ if (value == null && condition == null) {
88
+ return true
89
+ } else if (value === condition) {
90
+ return true;
91
+ } else if (Array.isArray(condition) || Array.isArray(value)) {
92
+ return Array.isArray(value)
93
+ ? value.some(v => module.exports(v, condition, referenceTime))
94
+ : condition.some(v => module.exports(value, v, referenceTime));
95
+ } else if (value == null || condition == null) {
96
+ return false;
97
+ } else if (condition instanceof RegExp) {
98
+ if (typeof value === 'string') return condition.test(value);
99
+ } else if (condition instanceof Date) {
100
+ value = value instanceof Date ? value.getTime() : new Date(value).getTime();
101
+ condition = condition.getTime();
102
+ if (!Number.isFinite(value) || !Number.isFinite(condition)) return false;
103
+ return value === condition;
104
+ } else if (Number.isNaN(value) || Number.isNaN(condition)) return false;
105
+ switch (typeof condition) {
106
+ case 'boolean':
107
+ return Boolean(value) === condition;
108
+ case 'string':
109
+ return String(value) === condition;
110
+ case 'number':
111
+ return Number(value) === condition;
112
+ case 'function':
113
+ return condition(value);
114
+ case 'object': {
115
+ let { min, max, not } = condition;
116
+ if (not != null) return !module.exports(value, not, referenceTime);
117
+
118
+ // Parse Grafana-style time intervals for min and max
119
+ if (typeof min === 'string') {
120
+ const parsed = parseTimeInterval(min, referenceTime);
121
+ if (parsed) min = parsed;
122
+ }
123
+ if (typeof max === 'string') {
124
+ const parsed = parseTimeInterval(max, referenceTime);
125
+ if (parsed) max = parsed;
126
+ }
127
+
128
+ console.log({ min, max, value });
129
+ if (value instanceof Date) {
130
+ value = value.getTime();
131
+ if (!Number.isFinite(value)) return false;
132
+ if (min != null) min = new Date(min).getTime();
133
+ if (max != null) max = new Date(max).getTime();
134
+ } else if (min instanceof Date || max instanceof Date) {
135
+ value = new Date(value).getTime();
136
+ if (!Number.isFinite(value)) return false;
137
+ }
138
+ if (min instanceof Date) min = min.getTime();
139
+ if (max instanceof Date) max = max.getTime();
140
+ if (Number.isNaN(min)) return false;
141
+ if (Number.isNaN(max)) return false;
142
+ if (min != null && (value < min || value === -Infinity || min === Infinity))
143
+ return false;
144
+ if (max != null && (value > max || value === Infinity || max === -Infinity))
145
+ return false;
146
+ if (min != null || max != null) return true;
147
+ if (typeof value === 'object' && value && condition) return module.exports(value, condition, referenceTime);
148
+ }
149
+ }
150
+ }
151
+
152
+ module.exports = function (factValue, ruleValue, referenceTime = Date.now()) {
153
+ if (factValue === ruleValue) return true;
154
+ if (ruleValue && typeof ruleValue === 'object' && 'not' in ruleValue && Object.keys(ruleValue).length === 1) return !module.exports(factValue, ruleValue.not, referenceTime);
155
+ if (
156
+ Array.isArray(factValue) ||
157
+ Array.isArray(ruleValue) ||
158
+ !isPlainObject(factValue) ||
159
+ !isPlainObject(ruleValue)
160
+ )
161
+ return match(referenceTime)(factValue, ruleValue);
162
+ if (factValue && ruleValue && typeof factValue === 'object' && typeof ruleValue === 'object') {
163
+ const nullFilter = Object.entries(ruleValue).filter(([, value]) => value == null || Array.isArray(value));
164
+ if (nullFilter.length > 0) factValue = { ...Object.fromEntries(nullFilter), ...factValue };
165
+ };
166
+ return isMatchWith(factValue, ruleValue, match(referenceTime));
167
+ };
package/package.json CHANGED
@@ -1,12 +1,25 @@
1
1
  {
2
2
  "name": "@infitx/match",
3
- "version": "0.0.1",
4
- "description": "",
5
- "license": "ISC",
6
- "author": "",
7
- "type": "commonjs",
3
+ "version": "1.3.2",
4
+ "description": "Object pattern matching utility",
8
5
  "main": "index.js",
9
6
  "scripts": {
10
- "test": "echo \"Error: no test specified\" && exit 1"
11
- }
12
- }
7
+ "build": "true",
8
+ "ci-unit": "JEST_JUNIT_OUTPUT_DIR=coverage jest --ci --reporters=default --reporters=jest-junit --outputFile=./coverage/junit.xml",
9
+ "ci-publish": "npm publish --access public --provenance",
10
+ "watch": "jest --watchAll"
11
+ },
12
+ "dependencies": {
13
+ "lodash": "^4.17.21"
14
+ },
15
+ "devDependencies": {
16
+ "yaml": "^2.8.2",
17
+ "jest": "^30.2.0",
18
+ "jest-junit": "^16.0.0"
19
+ },
20
+ "repository": {
21
+ "url": "git+https://github.com/infitx-org/release-cd.git"
22
+ },
23
+ "author": "Kalin Krustev",
24
+ "license": "Apache-2.0"
25
+ }