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