@posthog/core 1.48.10 → 1.48.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/featureFlagLocalEvaluation.d.ts +35 -0
- package/dist/featureFlagLocalEvaluation.d.ts.map +1 -0
- package/dist/featureFlagLocalEvaluation.js +352 -0
- package/dist/featureFlagLocalEvaluation.mjs +294 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +164 -79
- package/dist/index.mjs +2 -1
- package/dist/surveys/index.d.ts +1 -0
- package/dist/surveys/index.d.ts.map +1 -1
- package/dist/surveys/index.js +13 -0
- package/dist/surveys/index.mjs +2 -1
- package/dist/surveys/property-matching.d.ts +11 -0
- package/dist/surveys/property-matching.d.ts.map +1 -0
- package/dist/surveys/property-matching.js +89 -0
- package/dist/surveys/property-matching.mjs +46 -0
- package/dist/types.d.ts +11 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/featureFlagLocalEvaluation.ts +375 -0
- package/src/index.ts +19 -0
- package/src/surveys/index.ts +1 -0
- package/src/surveys/property-matching.spec.ts +174 -0
- package/src/surveys/property-matching.ts +75 -0
- package/src/types.ts +17 -3
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { PropertyFilters, PropertyOperator } from '../types'
|
|
2
|
+
import { isMatchingRegex, isValidRegex, matchPropertyFilters, propertyComparisons } from './property-matching'
|
|
3
|
+
|
|
4
|
+
const operators: PropertyOperator[] = [
|
|
5
|
+
'exact',
|
|
6
|
+
'is_not',
|
|
7
|
+
'regex',
|
|
8
|
+
'not_regex',
|
|
9
|
+
'icontains',
|
|
10
|
+
'not_icontains',
|
|
11
|
+
'gt',
|
|
12
|
+
'lt',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
const filter = (operator: PropertyOperator, values: string[]): PropertyFilters => ({
|
|
16
|
+
property: { operator, values },
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('shared property matching contract', () => {
|
|
20
|
+
describe('regex validation', () => {
|
|
21
|
+
it('validates and matches regular expressions without throwing', () => {
|
|
22
|
+
expect(isValidRegex('^/docs/.*')).toBe(true)
|
|
23
|
+
expect(isMatchingRegex('/docs/getting-started', '^/docs/')).toBe(true)
|
|
24
|
+
expect(isMatchingRegex('/pricing', '^/docs/')).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('returns false for invalid regular expressions', () => {
|
|
28
|
+
expect(isValidRegex('[invalid')).toBe(false)
|
|
29
|
+
expect(isMatchingRegex('anything', '[invalid')).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('property comparisons', () => {
|
|
34
|
+
it.each<{
|
|
35
|
+
operator: PropertyOperator
|
|
36
|
+
targets: string[]
|
|
37
|
+
values: string[]
|
|
38
|
+
expected: boolean
|
|
39
|
+
}>([
|
|
40
|
+
{ operator: 'exact', targets: ['premium'], values: ['basic', 'premium'], expected: true },
|
|
41
|
+
{ operator: 'exact', targets: ['premium'], values: ['basic', 'trial'], expected: false },
|
|
42
|
+
{ operator: 'is_not', targets: ['basic', 'trial'], values: ['premium', 'enterprise'], expected: true },
|
|
43
|
+
{ operator: 'is_not', targets: ['basic', 'trial'], values: ['premium', 'trial'], expected: false },
|
|
44
|
+
{ operator: 'regex', targets: ['^/app/', '^/docs/'], values: ['/home', '/docs/start'], expected: true },
|
|
45
|
+
{ operator: 'regex', targets: ['^/app/', '^/docs/'], values: ['/home', '/pricing'], expected: false },
|
|
46
|
+
{ operator: 'not_regex', targets: ['^/app/', '^/docs/'], values: ['/home', '/pricing'], expected: true },
|
|
47
|
+
{ operator: 'not_regex', targets: ['^/app/', '^/docs/'], values: ['/home', '/docs/start'], expected: false },
|
|
48
|
+
{ operator: 'icontains', targets: ['CHECKOUT'], values: ['home', 'Start Checkout'], expected: true },
|
|
49
|
+
{ operator: 'icontains', targets: ['CHECKOUT'], values: ['home', 'pricing'], expected: false },
|
|
50
|
+
{ operator: 'not_icontains', targets: ['SPAM', 'BOT'], values: ['welcome', 'human'], expected: true },
|
|
51
|
+
{ operator: 'not_icontains', targets: ['SPAM', 'BOT'], values: ['welcome', 'chatBot'], expected: false },
|
|
52
|
+
{ operator: 'gt', targets: ['10', '20'], values: ['9', '21'], expected: true },
|
|
53
|
+
{ operator: 'gt', targets: ['10', '20'], values: ['8', '9'], expected: false },
|
|
54
|
+
{ operator: 'lt', targets: ['10', '20'], values: ['21', '9'], expected: true },
|
|
55
|
+
{ operator: 'lt', targets: ['10', '20'], values: ['20', '21'], expected: false },
|
|
56
|
+
])('$operator preserves its array quantifier semantics', ({ operator, targets, values, expected }) => {
|
|
57
|
+
expect(propertyComparisons[operator](targets, values)).toBe(expected)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it.each<{
|
|
61
|
+
operator: PropertyOperator
|
|
62
|
+
expected: boolean
|
|
63
|
+
}>([
|
|
64
|
+
{ operator: 'exact', expected: false },
|
|
65
|
+
{ operator: 'is_not', expected: true },
|
|
66
|
+
{ operator: 'regex', expected: false },
|
|
67
|
+
{ operator: 'not_regex', expected: true },
|
|
68
|
+
{ operator: 'icontains', expected: false },
|
|
69
|
+
{ operator: 'not_icontains', expected: true },
|
|
70
|
+
{ operator: 'gt', expected: false },
|
|
71
|
+
{ operator: 'lt', expected: false },
|
|
72
|
+
])('$operator preserves empty-target behavior', ({ operator, expected }) => {
|
|
73
|
+
expect(propertyComparisons[operator]([], ['value'])).toBe(expected)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('preserves parseFloat numeric coercion', () => {
|
|
77
|
+
expect(propertyComparisons.gt(['9widgets'], ['10items'])).toBe(true)
|
|
78
|
+
expect(propertyComparisons.lt(['10items'], ['9widgets'])).toBe(true)
|
|
79
|
+
expect(propertyComparisons.gt(['9'], ['not a number'])).toBe(false)
|
|
80
|
+
expect(propertyComparisons.gt(['not a number'], ['10'])).toBe(false)
|
|
81
|
+
expect(propertyComparisons.lt(['10'], ['10items'])).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('treats invalid regexes as non-matches before applying negative quantifiers', () => {
|
|
85
|
+
expect(propertyComparisons.regex(['[invalid'], ['value'])).toBe(false)
|
|
86
|
+
expect(propertyComparisons.not_regex(['[invalid'], ['value'])).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('map-level matching', () => {
|
|
91
|
+
it('matches absent and empty filter maps', () => {
|
|
92
|
+
expect(matchPropertyFilters(undefined, undefined)).toBe(true)
|
|
93
|
+
expect(matchPropertyFilters({}, undefined)).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('requires every configured property filter to match', () => {
|
|
97
|
+
expect(
|
|
98
|
+
matchPropertyFilters(
|
|
99
|
+
{
|
|
100
|
+
plan: { values: ['premium'], operator: 'exact' },
|
|
101
|
+
role: { values: ['admin'], operator: 'is_not' },
|
|
102
|
+
},
|
|
103
|
+
{ plan: 'premium', role: 'member' }
|
|
104
|
+
)
|
|
105
|
+
).toBe(true)
|
|
106
|
+
expect(
|
|
107
|
+
matchPropertyFilters(
|
|
108
|
+
{
|
|
109
|
+
plan: { values: ['premium'], operator: 'exact' },
|
|
110
|
+
role: { values: ['admin'], operator: 'is_not' },
|
|
111
|
+
},
|
|
112
|
+
{ plan: 'premium', role: 'admin' }
|
|
113
|
+
)
|
|
114
|
+
).toBe(false)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it.each(operators)('rejects missing and null values for %s, including negative operators', (operator) => {
|
|
118
|
+
expect(matchPropertyFilters(filter(operator, ['target']), {})).toBe(false)
|
|
119
|
+
expect(matchPropertyFilters(filter(operator, ['target']), { property: undefined })).toBe(false)
|
|
120
|
+
expect(matchPropertyFilters(filter(operator, ['target']), { property: null })).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('coerces each event property to one string value', () => {
|
|
124
|
+
expect(matchPropertyFilters(filter('exact', ['5']), { property: 5 })).toBe(true)
|
|
125
|
+
expect(matchPropertyFilters(filter('exact', ['true']), { property: true })).toBe(true)
|
|
126
|
+
expect(matchPropertyFilters(filter('exact', ['premium,vip']), { property: ['premium', 'vip'] })).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it.each<{
|
|
130
|
+
operator: PropertyOperator
|
|
131
|
+
value: string
|
|
132
|
+
expected: boolean
|
|
133
|
+
}>([
|
|
134
|
+
{ operator: 'exact', value: 'target', expected: true },
|
|
135
|
+
{ operator: 'is_not', value: 'other', expected: true },
|
|
136
|
+
{ operator: 'regex', value: '/docs/start', expected: true },
|
|
137
|
+
{ operator: 'not_regex', value: '/pricing', expected: true },
|
|
138
|
+
{ operator: 'icontains', value: 'TARGET value', expected: true },
|
|
139
|
+
{ operator: 'not_icontains', value: 'other value', expected: true },
|
|
140
|
+
{ operator: 'gt', value: '11items', expected: true },
|
|
141
|
+
{ operator: 'lt', value: '9items', expected: true },
|
|
142
|
+
])('uses the shared $operator comparison', ({ operator, value, expected }) => {
|
|
143
|
+
const target =
|
|
144
|
+
operator === 'regex' || operator === 'not_regex'
|
|
145
|
+
? '^/docs/'
|
|
146
|
+
: operator === 'gt' || operator === 'lt'
|
|
147
|
+
? '10items'
|
|
148
|
+
: 'target'
|
|
149
|
+
expect(matchPropertyFilters(filter(operator, [target]), { property: value })).toBe(expected)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('returns false for invalid regex and numeric targets', () => {
|
|
153
|
+
expect(matchPropertyFilters(filter('regex', ['[invalid']), { property: 'value' })).toBe(false)
|
|
154
|
+
expect(matchPropertyFilters(filter('gt', ['not a number']), { property: '10' })).toBe(false)
|
|
155
|
+
expect(matchPropertyFilters(filter('lt', ['not a number']), { property: '10' })).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('preserves empty-target behavior through the map matcher', () => {
|
|
159
|
+
expect(matchPropertyFilters(filter('exact', []), { property: 'value' })).toBe(false)
|
|
160
|
+
expect(matchPropertyFilters(filter('regex', []), { property: 'value' })).toBe(false)
|
|
161
|
+
expect(matchPropertyFilters(filter('icontains', []), { property: 'value' })).toBe(false)
|
|
162
|
+
expect(matchPropertyFilters(filter('gt', []), { property: '10' })).toBe(false)
|
|
163
|
+
expect(matchPropertyFilters(filter('lt', []), { property: '10' })).toBe(false)
|
|
164
|
+
expect(matchPropertyFilters(filter('is_not', []), { property: 'value' })).toBe(true)
|
|
165
|
+
expect(matchPropertyFilters(filter('not_regex', []), { property: 'value' })).toBe(true)
|
|
166
|
+
expect(matchPropertyFilters(filter('not_icontains', []), { property: 'value' })).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('returns false for an unknown operator from malformed remote data', () => {
|
|
170
|
+
const malformedFilters = filter('unknown' as PropertyOperator, ['target'])
|
|
171
|
+
expect(matchPropertyFilters(malformedFilters, { property: 'target' })).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Properties } from '@posthog/types'
|
|
2
|
+
|
|
3
|
+
import type { PropertyFilters, PropertyOperator } from '../types'
|
|
4
|
+
|
|
5
|
+
export const isValidRegex = (pattern: string): boolean => {
|
|
6
|
+
try {
|
|
7
|
+
new RegExp(pattern)
|
|
8
|
+
} catch {
|
|
9
|
+
return false
|
|
10
|
+
}
|
|
11
|
+
return true
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const isMatchingRegex = (value: string, pattern: string): boolean => {
|
|
15
|
+
if (!isValidRegex(pattern)) {
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
return new RegExp(pattern).test(value)
|
|
21
|
+
} catch {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const toLowerCase = (value: string): string => value.toLowerCase()
|
|
27
|
+
|
|
28
|
+
export const propertyComparisons: Record<PropertyOperator, (targets: string[], values: string[]) => boolean> = {
|
|
29
|
+
exact: (targets, values) => values.some((value) => targets.some((target) => value === target)),
|
|
30
|
+
is_not: (targets, values) => values.every((value) => targets.every((target) => value !== target)),
|
|
31
|
+
regex: (targets, values) => values.some((value) => targets.some((target) => isMatchingRegex(value, target))),
|
|
32
|
+
not_regex: (targets, values) => values.every((value) => targets.every((target) => !isMatchingRegex(value, target))),
|
|
33
|
+
icontains: (targets, values) =>
|
|
34
|
+
values.map(toLowerCase).some((value) => targets.map(toLowerCase).some((target) => value.includes(target))),
|
|
35
|
+
not_icontains: (targets, values) =>
|
|
36
|
+
values.map(toLowerCase).every((value) => targets.map(toLowerCase).every((target) => !value.includes(target))),
|
|
37
|
+
gt: (targets, values) =>
|
|
38
|
+
values.some((value) => {
|
|
39
|
+
const numValue = parseFloat(value)
|
|
40
|
+
return !isNaN(numValue) && targets.some((target) => numValue > parseFloat(target))
|
|
41
|
+
}),
|
|
42
|
+
lt: (targets, values) =>
|
|
43
|
+
values.some((value) => {
|
|
44
|
+
const numValue = parseFloat(value)
|
|
45
|
+
return !isNaN(numValue) && targets.some((target) => numValue < parseFloat(target))
|
|
46
|
+
}),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Matches every configured filter against event properties. Missing and null
|
|
51
|
+
* properties never match, including for negative operators.
|
|
52
|
+
*/
|
|
53
|
+
export function matchPropertyFilters(
|
|
54
|
+
propertyFilters: PropertyFilters | undefined,
|
|
55
|
+
eventProperties: Properties | undefined
|
|
56
|
+
): boolean {
|
|
57
|
+
if (!propertyFilters) {
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return Object.entries(propertyFilters).every(([propertyName, filter]) => {
|
|
62
|
+
const eventPropertyValue = eventProperties?.[propertyName]
|
|
63
|
+
|
|
64
|
+
if (eventPropertyValue === undefined || eventPropertyValue === null) {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const comparisonFunction = propertyComparisons[filter.operator]
|
|
69
|
+
if (!comparisonFunction) {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return comparisonFunction(filter.values, [String(eventPropertyValue)])
|
|
74
|
+
})
|
|
75
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -832,6 +832,22 @@ export const SurveyMatchType = {
|
|
|
832
832
|
} as const
|
|
833
833
|
export type SurveyMatchType = (typeof SurveyMatchType)[keyof typeof SurveyMatchType]
|
|
834
834
|
|
|
835
|
+
export type PropertyMatchType = SurveyMatchType
|
|
836
|
+
export type PropertyOperator = PropertyMatchType | 'gt' | 'lt'
|
|
837
|
+
|
|
838
|
+
export type PropertyFilters = Record<
|
|
839
|
+
string,
|
|
840
|
+
{
|
|
841
|
+
values: string[]
|
|
842
|
+
operator: PropertyOperator
|
|
843
|
+
}
|
|
844
|
+
>
|
|
845
|
+
|
|
846
|
+
export interface SurveyEventWithFilters {
|
|
847
|
+
name: string
|
|
848
|
+
propertyFilters?: PropertyFilters
|
|
849
|
+
}
|
|
850
|
+
|
|
835
851
|
export const SurveySchedule = {
|
|
836
852
|
Once: 'once',
|
|
837
853
|
Recurring: 'recurring',
|
|
@@ -881,9 +897,7 @@ export type Survey = {
|
|
|
881
897
|
urlMatchType?: SurveyMatchType
|
|
882
898
|
events?: {
|
|
883
899
|
repeatedActivation?: boolean
|
|
884
|
-
values?:
|
|
885
|
-
name: string
|
|
886
|
-
}[]
|
|
900
|
+
values?: SurveyEventWithFilters[]
|
|
887
901
|
}
|
|
888
902
|
actions?: {
|
|
889
903
|
values: SurveyActionType[]
|