@posthog/convex 0.2.32 → 1.0.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 (53) hide show
  1. package/README.md +132 -16
  2. package/dist/client/feature-flags/crypto.d.ts +2 -0
  3. package/dist/client/feature-flags/crypto.d.ts.map +1 -0
  4. package/dist/client/feature-flags/crypto.js +11 -0
  5. package/dist/client/feature-flags/crypto.js.map +1 -0
  6. package/dist/client/feature-flags/evaluator.d.ts +47 -0
  7. package/dist/client/feature-flags/evaluator.d.ts.map +1 -0
  8. package/dist/client/feature-flags/evaluator.js +346 -0
  9. package/dist/client/feature-flags/evaluator.js.map +1 -0
  10. package/dist/client/feature-flags/index.d.ts +4 -0
  11. package/dist/client/feature-flags/index.d.ts.map +1 -0
  12. package/dist/client/feature-flags/index.js +3 -0
  13. package/dist/client/feature-flags/index.js.map +1 -0
  14. package/dist/client/feature-flags/match-property.d.ts +12 -0
  15. package/dist/client/feature-flags/match-property.d.ts.map +1 -0
  16. package/dist/client/feature-flags/match-property.js +340 -0
  17. package/dist/client/feature-flags/match-property.js.map +1 -0
  18. package/dist/client/feature-flags/types.d.ts +63 -0
  19. package/dist/client/feature-flags/types.d.ts.map +1 -0
  20. package/dist/client/feature-flags/types.js +2 -0
  21. package/dist/client/feature-flags/types.js.map +1 -0
  22. package/dist/client/index.d.ts +71 -36
  23. package/dist/client/index.d.ts.map +1 -1
  24. package/dist/client/index.js +143 -32
  25. package/dist/client/index.js.map +1 -1
  26. package/dist/component/_generated/component.d.ts +8 -35
  27. package/dist/component/_generated/component.d.ts.map +1 -1
  28. package/dist/component/lib.d.ts +76 -46
  29. package/dist/component/lib.d.ts.map +1 -1
  30. package/dist/component/lib.js +311 -99
  31. package/dist/component/lib.js.map +1 -1
  32. package/dist/component/schema.d.ts +18 -1
  33. package/dist/component/schema.d.ts.map +1 -1
  34. package/dist/component/schema.js +16 -2
  35. package/dist/component/schema.js.map +1 -1
  36. package/dist/component/version.d.ts +2 -0
  37. package/dist/component/version.d.ts.map +1 -0
  38. package/dist/component/version.js +2 -0
  39. package/dist/component/version.js.map +1 -0
  40. package/package.json +5 -5
  41. package/src/client/feature-flags/crypto.ts +12 -0
  42. package/src/client/feature-flags/evaluator.test.ts +401 -0
  43. package/src/client/feature-flags/evaluator.ts +467 -0
  44. package/src/client/feature-flags/index.ts +15 -0
  45. package/src/client/feature-flags/match-property.test.ts +75 -0
  46. package/src/client/feature-flags/match-property.ts +347 -0
  47. package/src/client/feature-flags/types.ts +72 -0
  48. package/src/client/index.test.ts +60 -12
  49. package/src/client/index.ts +227 -70
  50. package/src/component/_generated/component.ts +7 -50
  51. package/src/component/lib.ts +340 -127
  52. package/src/component/schema.ts +16 -2
  53. package/src/component/version.ts +1 -0
@@ -0,0 +1,467 @@
1
+ import type {
2
+ FeatureFlagCondition,
3
+ FeatureFlagEvaluationContext,
4
+ FeatureFlagValue,
5
+ FlagDefinitions,
6
+ FlagPropertyValue,
7
+ JsonType,
8
+ PostHogFeatureFlag,
9
+ } from './types.js'
10
+ import { hashSHA1 } from './crypto.js'
11
+ import { InconclusiveMatchError, RequiresServerEvaluation, matchCohort, matchProperty } from './match-property.js'
12
+
13
+ // Matches posthog-node's hashing constant exactly; the value is larger than Number.MAX_SAFE_INTEGER
14
+ // by design and we don't want the no-loss-of-precision rule to coerce it to a different number.
15
+ // eslint-disable-next-line no-loss-of-precision
16
+ const LONG_SCALE = 0xfffffffffffffff
17
+
18
+ async function _hash(key: string, bucketingValue: string, salt: string = ''): Promise<number> {
19
+ const hashString = await hashSHA1(`${key}.${bucketingValue}${salt}`)
20
+ return parseInt(hashString.slice(0, 15), 16) / LONG_SCALE
21
+ }
22
+
23
+ export type EvaluationResult = {
24
+ value: FeatureFlagValue
25
+ payload: JsonType | null
26
+ }
27
+
28
+ export class LocalFeatureFlagEvaluator {
29
+ readonly flags: PostHogFeatureFlag[]
30
+ readonly flagsByKey: Record<string, PostHogFeatureFlag>
31
+ readonly groupTypeMapping: Record<string, string>
32
+ readonly cohorts: FlagDefinitions['cohorts']
33
+ debugMode: boolean = false
34
+
35
+ constructor(definitions: FlagDefinitions) {
36
+ this.flags = definitions.flags ?? []
37
+ this.flagsByKey = this.flags.reduce<Record<string, PostHogFeatureFlag>>((acc, flag) => {
38
+ acc[flag.key] = flag
39
+ return acc
40
+ }, {})
41
+ this.groupTypeMapping = definitions.groupTypeMapping ?? {}
42
+ this.cohorts = definitions.cohorts ?? {}
43
+ }
44
+
45
+ debug(enabled: boolean = true): void {
46
+ this.debugMode = enabled
47
+ }
48
+
49
+ private logMsgIfDebug(fn: () => void): void {
50
+ if (this.debugMode) fn()
51
+ }
52
+
53
+ private createEvaluationContext(
54
+ distinctId: string,
55
+ groups: Record<string, string> = {},
56
+ personProperties: Record<string, any> = {},
57
+ groupProperties: Record<string, Record<string, any>> = {}
58
+ ): FeatureFlagEvaluationContext {
59
+ return { distinctId, groups, personProperties, groupProperties, evaluationCache: {} }
60
+ }
61
+
62
+ /**
63
+ * Evaluate a single flag locally. Returns the value or `undefined` if eval was inconclusive.
64
+ * `undefined` means the caller has no way to determine the flag value locally — typically
65
+ * because the flag uses experience continuity, a static cohort, or properties that weren't
66
+ * provided.
67
+ */
68
+ async getFeatureFlag(
69
+ key: string,
70
+ distinctId: string,
71
+ groups: Record<string, string> = {},
72
+ personProperties: Record<string, any> = {},
73
+ groupProperties: Record<string, Record<string, any>> = {}
74
+ ): Promise<FeatureFlagValue | undefined> {
75
+ const flag = this.flagsByKey[key]
76
+ if (flag === undefined) return undefined
77
+
78
+ const ctx = this.createEvaluationContext(distinctId, groups, personProperties, groupProperties)
79
+ try {
80
+ const { value } = await this.computeFlagAndPayloadLocally(flag, ctx)
81
+ return value
82
+ } catch (e) {
83
+ if (e instanceof RequiresServerEvaluation || e instanceof InconclusiveMatchError) {
84
+ this.logMsgIfDebug(() =>
85
+ console.debug(
86
+ `[FEATURE FLAGS] ${(e as Error).name} when computing flag locally: ${key}: ${(e as Error).message}`
87
+ )
88
+ )
89
+ return undefined
90
+ }
91
+ throw e
92
+ }
93
+ }
94
+
95
+ async getFeatureFlagResult(
96
+ key: string,
97
+ distinctId: string,
98
+ groups: Record<string, string> = {},
99
+ personProperties: Record<string, any> = {},
100
+ groupProperties: Record<string, Record<string, any>> = {}
101
+ ): Promise<EvaluationResult | undefined> {
102
+ const flag = this.flagsByKey[key]
103
+ if (flag === undefined) return undefined
104
+
105
+ const ctx = this.createEvaluationContext(distinctId, groups, personProperties, groupProperties)
106
+ try {
107
+ return await this.computeFlagAndPayloadLocally(flag, ctx)
108
+ } catch (e) {
109
+ if (e instanceof RequiresServerEvaluation || e instanceof InconclusiveMatchError) {
110
+ this.logMsgIfDebug(() =>
111
+ console.debug(
112
+ `[FEATURE FLAGS] ${(e as Error).name} when computing flag locally: ${key}: ${(e as Error).message}`
113
+ )
114
+ )
115
+ return undefined
116
+ }
117
+ throw e
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Returns the payload for a flag value, or `null` when the flag is unknown / evaluated to a
123
+ * non-matching value / has no payload configured. Returns `undefined` when the flag couldn't
124
+ * be evaluated locally — mirroring `getFeatureFlag` so callers can tell apart "no payload"
125
+ * from "eval unavailable".
126
+ */
127
+ async getFeatureFlagPayload(
128
+ key: string,
129
+ distinctId: string,
130
+ matchValue: FeatureFlagValue | undefined,
131
+ groups: Record<string, string> = {},
132
+ personProperties: Record<string, any> = {},
133
+ groupProperties: Record<string, Record<string, any>> = {}
134
+ ): Promise<JsonType | null | undefined> {
135
+ const flag = this.flagsByKey[key]
136
+ if (flag === undefined) return null
137
+
138
+ if (matchValue !== undefined) {
139
+ return this.getPayloadForValue(key, matchValue)
140
+ }
141
+
142
+ const result = await this.getFeatureFlagResult(key, distinctId, groups, personProperties, groupProperties)
143
+ if (result === undefined) return undefined
144
+ return result.payload
145
+ }
146
+
147
+ async getAllFlagsAndPayloads(
148
+ distinctId: string,
149
+ groups: Record<string, string> = {},
150
+ personProperties: Record<string, any> = {},
151
+ groupProperties: Record<string, Record<string, any>> = {},
152
+ flagKeys?: string[]
153
+ ): Promise<{ featureFlags: Record<string, FeatureFlagValue>; featureFlagPayloads: Record<string, JsonType> }> {
154
+ const featureFlags: Record<string, FeatureFlagValue> = {}
155
+ const featureFlagPayloads: Record<string, JsonType> = {}
156
+
157
+ const flagsToEvaluate = flagKeys ? flagKeys.map((k) => this.flagsByKey[k]).filter(Boolean) : this.flags
158
+
159
+ const sharedContext: FeatureFlagEvaluationContext = {
160
+ distinctId,
161
+ groups,
162
+ personProperties,
163
+ groupProperties,
164
+ evaluationCache: {},
165
+ }
166
+
167
+ await Promise.all(
168
+ flagsToEvaluate.map(async (flag) => {
169
+ try {
170
+ const { value, payload } = await this.computeFlagAndPayloadLocally(flag, sharedContext)
171
+ featureFlags[flag.key] = value
172
+ if (payload != null) featureFlagPayloads[flag.key] = payload
173
+ } catch (e) {
174
+ if (e instanceof RequiresServerEvaluation || e instanceof InconclusiveMatchError) {
175
+ this.logMsgIfDebug(() =>
176
+ console.debug(
177
+ `[FEATURE FLAGS] ${(e as Error).name} when computing flag locally: ${flag.key}: ${(e as Error).message}`
178
+ )
179
+ )
180
+ return
181
+ }
182
+ throw e
183
+ }
184
+ })
185
+ )
186
+
187
+ return { featureFlags, featureFlagPayloads }
188
+ }
189
+
190
+ async computeFlagAndPayloadLocally(
191
+ flag: PostHogFeatureFlag,
192
+ ctx: FeatureFlagEvaluationContext,
193
+ options: { matchValue?: FeatureFlagValue } = {}
194
+ ): Promise<EvaluationResult> {
195
+ const flagValue =
196
+ options.matchValue !== undefined ? options.matchValue : await this.computeFlagValueLocally(flag, ctx)
197
+ return { value: flagValue, payload: this.getPayloadForValue(flag.key, flagValue) }
198
+ }
199
+
200
+ private async computeFlagValueLocally(
201
+ flag: PostHogFeatureFlag,
202
+ ctx: FeatureFlagEvaluationContext
203
+ ): Promise<FeatureFlagValue> {
204
+ const { distinctId, groups, personProperties, groupProperties } = ctx
205
+
206
+ // Order matters: an inactive flag is always false regardless of continuity. Checking
207
+ // `ensure_experience_continuity` first would cause a disabled-but-continuity flag to come
208
+ // back as undefined instead of the correct `false`.
209
+ if (!flag.active) return false
210
+ if (flag.ensure_experience_continuity) {
211
+ throw new InconclusiveMatchError('Flag has experience continuity enabled')
212
+ }
213
+
214
+ const flagFilters = flag.filters || {}
215
+ const aggregation_group_type_index = flagFilters.aggregation_group_type_index
216
+
217
+ if (aggregation_group_type_index != undefined) {
218
+ const groupName = this.groupTypeMapping[String(aggregation_group_type_index)]
219
+ if (!groupName) {
220
+ this.logMsgIfDebug(() =>
221
+ console.warn(
222
+ `[FEATURE FLAGS] Unknown group type index ${aggregation_group_type_index} for feature flag ${flag.key}`
223
+ )
224
+ )
225
+ throw new InconclusiveMatchError('Flag has unknown group type index')
226
+ }
227
+
228
+ if (!(groupName in groups)) {
229
+ this.logMsgIfDebug(() =>
230
+ console.warn(`[FEATURE FLAGS] Can't compute group feature flag: ${flag.key} without group names passed in`)
231
+ )
232
+ return false
233
+ }
234
+
235
+ const focusedGroupProperties = groupProperties[groupName]
236
+ return await this.matchFeatureFlagProperties(flag, groups[groupName], focusedGroupProperties, ctx)
237
+ }
238
+
239
+ const bucketingValue = this.getBucketingValueForFlag(flag, distinctId, personProperties)
240
+ if (bucketingValue === undefined) {
241
+ this.logMsgIfDebug(() =>
242
+ console.warn(
243
+ `[FEATURE FLAGS] Can't compute feature flag: ${flag.key} without $device_id, falling back to server evaluation`
244
+ )
245
+ )
246
+ throw new InconclusiveMatchError(`Can't compute feature flag: ${flag.key} without $device_id`)
247
+ }
248
+ return await this.matchFeatureFlagProperties(flag, bucketingValue, personProperties, ctx)
249
+ }
250
+
251
+ private getBucketingValueForFlag(
252
+ flag: PostHogFeatureFlag,
253
+ distinctId: string,
254
+ properties: Record<string, any>
255
+ ): string | undefined {
256
+ if (flag.filters?.aggregation_group_type_index != undefined) return distinctId
257
+ if (flag.bucketing_identifier === 'device_id') {
258
+ const deviceId = properties?.$device_id
259
+ if (deviceId === undefined || deviceId === null || deviceId === '') return undefined
260
+ return deviceId
261
+ }
262
+ return distinctId
263
+ }
264
+
265
+ private getPayloadForValue(key: string, flagValue: FeatureFlagValue): JsonType | null {
266
+ if (flagValue === false || flagValue === null || flagValue === undefined) return null
267
+
268
+ let payload: JsonType | null = null
269
+ const payloads = this.flagsByKey[key]?.filters?.payloads
270
+ if (!payloads) return null
271
+
272
+ if (typeof flagValue === 'boolean') {
273
+ payload = payloads[flagValue.toString()] || null
274
+ } else if (typeof flagValue === 'string') {
275
+ payload = payloads[flagValue] || null
276
+ }
277
+
278
+ if (payload == null) return null
279
+ if (typeof payload === 'object') return payload
280
+ if (typeof payload === 'string') {
281
+ try {
282
+ return JSON.parse(payload)
283
+ } catch {
284
+ return payload
285
+ }
286
+ }
287
+ return payload
288
+ }
289
+
290
+ private async evaluateFlagDependency(
291
+ property: { key: string; value: FlagPropertyValue; dependency_chain?: string[] },
292
+ ctx: FeatureFlagEvaluationContext
293
+ ): Promise<boolean> {
294
+ const { evaluationCache } = ctx
295
+ const targetFlagKey = property.key
296
+
297
+ if (!('dependency_chain' in property)) {
298
+ throw new InconclusiveMatchError(
299
+ `Flag dependency property for '${targetFlagKey}' is missing required 'dependency_chain' field`
300
+ )
301
+ }
302
+ const dependencyChain = property.dependency_chain
303
+ if (!Array.isArray(dependencyChain)) {
304
+ throw new InconclusiveMatchError(
305
+ `Flag dependency property for '${targetFlagKey}' has an invalid 'dependency_chain'`
306
+ )
307
+ }
308
+ if (dependencyChain.length === 0) {
309
+ throw new InconclusiveMatchError(`Circular dependency detected for flag '${targetFlagKey}'`)
310
+ }
311
+
312
+ for (const depFlagKey of dependencyChain) {
313
+ if (!(depFlagKey in evaluationCache)) {
314
+ const depFlag = this.flagsByKey[depFlagKey]
315
+ if (!depFlag) {
316
+ throw new InconclusiveMatchError(`Missing flag dependency '${depFlagKey}' for flag '${targetFlagKey}'`)
317
+ }
318
+ if (!depFlag.active) {
319
+ evaluationCache[depFlagKey] = false
320
+ } else {
321
+ try {
322
+ evaluationCache[depFlagKey] = await this.computeFlagValueLocally(depFlag, ctx)
323
+ } catch (error) {
324
+ throw new InconclusiveMatchError(
325
+ `Error evaluating flag dependency '${depFlagKey}' for flag '${targetFlagKey}': ${error}`
326
+ )
327
+ }
328
+ }
329
+ }
330
+ const cached = evaluationCache[depFlagKey]
331
+ if (cached === null || cached === undefined) {
332
+ throw new InconclusiveMatchError(`Dependency '${depFlagKey}' could not be evaluated`)
333
+ }
334
+ }
335
+
336
+ return flagEvaluatesToExpectedValue(property.value, evaluationCache[targetFlagKey])
337
+ }
338
+
339
+ private async matchFeatureFlagProperties(
340
+ flag: PostHogFeatureFlag,
341
+ bucketingValue: string,
342
+ properties: Record<string, any>,
343
+ ctx: FeatureFlagEvaluationContext
344
+ ): Promise<FeatureFlagValue> {
345
+ const flagFilters = flag.filters || {}
346
+ const flagConditions = flagFilters.groups || []
347
+ const flagAggregation = flagFilters.aggregation_group_type_index
348
+ const { groups, groupProperties } = ctx
349
+ let isInconclusive = false
350
+ let result: FeatureFlagValue | undefined = undefined
351
+
352
+ for (const condition of flagConditions) {
353
+ try {
354
+ const conditionAggregation =
355
+ condition.aggregation_group_type_index !== undefined
356
+ ? condition.aggregation_group_type_index
357
+ : flagAggregation
358
+
359
+ let effectiveProperties = properties
360
+ let effectiveBucketingValue = bucketingValue
361
+
362
+ if (conditionAggregation !== flagAggregation) {
363
+ if (conditionAggregation !== null && conditionAggregation !== undefined) {
364
+ const groupName = this.groupTypeMapping[String(conditionAggregation)]
365
+ if (!groupName || !(groupName in groups)) {
366
+ this.logMsgIfDebug(() =>
367
+ console.debug(
368
+ `[FEATURE FLAGS] Skipping group condition for flag '${flag.key}': group type index ${conditionAggregation} not available`
369
+ )
370
+ )
371
+ continue
372
+ }
373
+ if (!(groupName in groupProperties)) {
374
+ isInconclusive = true
375
+ continue
376
+ }
377
+ effectiveProperties = groupProperties[groupName]
378
+ effectiveBucketingValue = groups[groupName]
379
+ }
380
+ }
381
+
382
+ if (await this.isConditionMatch(flag, effectiveBucketingValue, condition, effectiveProperties, ctx)) {
383
+ const variantOverride = condition.variant
384
+ const flagVariants = flagFilters.multivariate?.variants || []
385
+ if (variantOverride && flagVariants.some((variant) => variant.key === variantOverride)) {
386
+ result = variantOverride
387
+ } else {
388
+ result = (await this.getMatchingVariant(flag, effectiveBucketingValue)) || true
389
+ }
390
+ break
391
+ }
392
+ } catch (e) {
393
+ if (e instanceof RequiresServerEvaluation) throw e
394
+ if (e instanceof InconclusiveMatchError) {
395
+ isInconclusive = true
396
+ } else {
397
+ throw e
398
+ }
399
+ }
400
+ }
401
+
402
+ if (result !== undefined) return result
403
+ if (isInconclusive) {
404
+ throw new InconclusiveMatchError("Can't determine if feature flag is enabled or not with given properties")
405
+ }
406
+ return false
407
+ }
408
+
409
+ private async isConditionMatch(
410
+ flag: PostHogFeatureFlag,
411
+ bucketingValue: string,
412
+ condition: FeatureFlagCondition,
413
+ properties: Record<string, any>,
414
+ ctx: FeatureFlagEvaluationContext
415
+ ): Promise<boolean> {
416
+ const rolloutPercentage = condition.rollout_percentage
417
+ const warn = (msg: string): void => this.logMsgIfDebug(() => console.warn(msg))
418
+
419
+ if ((condition.properties || []).length > 0) {
420
+ for (const prop of condition.properties) {
421
+ let matches: boolean
422
+ if (prop.type === 'cohort') {
423
+ matches = matchCohort(prop, properties, this.cohorts, this.debugMode)
424
+ } else if (prop.type === 'flag') {
425
+ matches = await this.evaluateFlagDependency(prop, ctx)
426
+ } else {
427
+ matches = matchProperty(prop, properties, warn)
428
+ }
429
+ // `matchPropertyGroup` (cohort path) inverts on `negation`; the top-level flag condition
430
+ // path needs to do the same or any negated property on a flag-level filter quietly passes.
431
+ if (prop.negation) matches = !matches
432
+ if (!matches) return false
433
+ }
434
+ if (rolloutPercentage == undefined) return true
435
+ }
436
+
437
+ if (rolloutPercentage != undefined && (await _hash(flag.key, bucketingValue)) > rolloutPercentage / 100.0) {
438
+ return false
439
+ }
440
+ return true
441
+ }
442
+
443
+ private async getMatchingVariant(flag: PostHogFeatureFlag, bucketingValue: string): Promise<string | undefined> {
444
+ const hashValue = await _hash(flag.key, bucketingValue, 'variant')
445
+ return this.variantLookupTable(flag).find((v) => hashValue >= v.valueMin && hashValue < v.valueMax)?.key
446
+ }
447
+
448
+ private variantLookupTable(flag: PostHogFeatureFlag): { valueMin: number; valueMax: number; key: string }[] {
449
+ const table: { valueMin: number; valueMax: number; key: string }[] = []
450
+ let valueMin = 0
451
+ const multivariates = flag.filters?.multivariate?.variants || []
452
+ for (const variant of multivariates) {
453
+ const valueMax = valueMin + variant.rollout_percentage / 100.0
454
+ table.push({ valueMin, valueMax, key: variant.key })
455
+ valueMin = valueMax
456
+ }
457
+ return table
458
+ }
459
+ }
460
+
461
+ function flagEvaluatesToExpectedValue(expectedValue: FlagPropertyValue, flagValue: FeatureFlagValue): boolean {
462
+ if (typeof expectedValue === 'boolean') {
463
+ return expectedValue === flagValue || (typeof flagValue === 'string' && flagValue !== '' && expectedValue === true)
464
+ }
465
+ if (typeof expectedValue === 'string') return flagValue === expectedValue
466
+ return false
467
+ }
@@ -0,0 +1,15 @@
1
+ export { LocalFeatureFlagEvaluator } from './evaluator.js'
2
+ export { InconclusiveMatchError, RequiresServerEvaluation, matchProperty } from './match-property.js'
3
+ export type {
4
+ FeatureFlagBucketingIdentifier,
5
+ FeatureFlagCondition,
6
+ FeatureFlagEvaluationContext,
7
+ FeatureFlagResult,
8
+ FeatureFlagValue,
9
+ FlagDefinitions,
10
+ FlagProperty,
11
+ FlagPropertyValue,
12
+ JsonType,
13
+ PostHogFeatureFlag,
14
+ PropertyGroup,
15
+ } from './types.js'
@@ -0,0 +1,75 @@
1
+ import { describe, test, expect } from '@jest/globals'
2
+ import { InconclusiveMatchError, matchProperty } from './match-property.js'
3
+ import type { FlagProperty } from './types.js'
4
+
5
+ function prop(operator: string, value: FlagProperty['value']): FlagProperty {
6
+ return { key: 'k', value, operator }
7
+ }
8
+
9
+ describe('matchProperty — numeric comparisons', () => {
10
+ test.each([
11
+ // string override vs numeric value — must compare numerically, not lexicographically.
12
+ { op: 'gt', value: 9, override: '10', expected: true },
13
+ { op: 'gt', value: 100, override: '90', expected: false },
14
+ { op: 'gte', value: 10, override: '10', expected: true },
15
+ { op: 'lt', value: 9, override: '10', expected: false },
16
+ { op: 'lte', value: 10, override: '10', expected: true },
17
+ // number override vs string value
18
+ { op: 'gt', value: '9', override: 10, expected: true },
19
+ { op: 'lt', value: '10', override: 9, expected: true },
20
+ // number-on-number sanity
21
+ { op: 'gt', value: 5, override: 6, expected: true },
22
+ { op: 'lt', value: 5, override: 6, expected: false },
23
+ ])('$op $value vs $override -> $expected', ({ op, value, override, expected }) => {
24
+ expect(matchProperty(prop(op, value), { k: override })).toBe(expected)
25
+ })
26
+
27
+ test('falls back to lexicographic comparison when neither side is numeric', () => {
28
+ expect(matchProperty(prop('gt', 'b'), { k: 'c' })).toBe(true)
29
+ expect(matchProperty(prop('lt', 'b'), { k: 'a' })).toBe(true)
30
+ })
31
+
32
+ test('non-numeric strings do not produce NaN-leaked comparisons', () => {
33
+ // Pre-fix: `parseFloat('abc') = NaN`, `NaN != null` was true, comparisons silently returned
34
+ // false. Now we fall back to lexicographic comparison so the result is meaningful.
35
+ expect(matchProperty(prop('gt', 'abc'), { k: 'abd' })).toBe(true)
36
+ expect(matchProperty(prop('lt', 'abc'), { k: 'abb' })).toBe(true)
37
+ })
38
+ })
39
+
40
+ describe('matchProperty — is_not_set', () => {
41
+ test('returns true when the property is absent', () => {
42
+ expect(matchProperty({ key: 'missing', value: 'whatever', operator: 'is_not_set' }, {})).toBe(true)
43
+ })
44
+
45
+ test('returns false when the property is present', () => {
46
+ expect(matchProperty({ key: 'plan', value: 'whatever', operator: 'is_not_set' }, { plan: 'pro' })).toBe(false)
47
+ })
48
+
49
+ test('treats null-valued property as still set (returns false)', () => {
50
+ // `null` counts as present in propertyValues; only genuinely missing keys read as "not set".
51
+ expect(matchProperty({ key: 'plan', value: 'whatever', operator: 'is_not_set' }, { plan: null })).toBe(false)
52
+ })
53
+ })
54
+
55
+ describe('matchProperty — is_set', () => {
56
+ test('returns true when the property is present with a non-null value', () => {
57
+ expect(matchProperty({ key: 'plan', value: '', operator: 'is_set' }, { plan: 'pro' })).toBe(true)
58
+ })
59
+
60
+ test('returns true when the property is present with a null value', () => {
61
+ // `is_set` is about key presence, not value. Pre-fix, the null guard short-circuited and
62
+ // returned false here.
63
+ expect(matchProperty({ key: 'plan', value: '', operator: 'is_set' }, { plan: null })).toBe(true)
64
+ })
65
+
66
+ test('throws InconclusiveMatchError when the property is absent', () => {
67
+ expect(() => matchProperty({ key: 'plan', value: '', operator: 'is_set' }, {})).toThrow(InconclusiveMatchError)
68
+ })
69
+ })
70
+
71
+ describe('matchProperty — error cases', () => {
72
+ test('throws InconclusiveMatchError when key is absent for non-is_not_set operators', () => {
73
+ expect(() => matchProperty(prop('exact', 'x'), {})).toThrow(InconclusiveMatchError)
74
+ })
75
+ })