@posthog/core 1.48.10 → 1.48.12

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/src/index.ts CHANGED
@@ -7,6 +7,25 @@ export {
7
7
  MINIMAL_FLAG_CALLED_EVENT_CAMPAIGN_PROPERTIES,
8
8
  minimizeFlagCalledEventProperties,
9
9
  } from './featureFlagUtils'
10
+ export {
11
+ getFeatureFlagHash,
12
+ getFeatureFlagVariant,
13
+ getFeatureFlagVariantLookupTable,
14
+ hashSHA1,
15
+ InconclusiveMatchError,
16
+ matchFeatureFlagProperty,
17
+ parseFeatureFlagSemver,
18
+ relativeDateParseForFeatureFlagMatching,
19
+ resolveFeatureFlagPayload,
20
+ } from './featureFlagLocalEvaluation'
21
+ export type {
22
+ FeatureFlagProperty,
23
+ FeatureFlagPropertyValue,
24
+ FeatureFlagSemverParsingPolicy,
25
+ FeatureFlagVariant,
26
+ FeatureFlagVariantLookupEntry,
27
+ MatchFeatureFlagPropertyOptions,
28
+ } from './featureFlagLocalEvaluation'
10
29
  export {
11
30
  gzipCompress,
12
31
  isGzipData,
@@ -18,3 +18,4 @@ export {
18
18
  } from './translations'
19
19
  export { canSurveyActivateRepeatedly, doesSurveyActivateByEvent, isSurveyIterationBased } from './activation'
20
20
  export { getSurveyIterationKey, isSurveyKeyForSurvey, type SurveyWithIteration } from './keys'
21
+ export { isMatchingRegex, isValidRegex, matchPropertyFilters, propertyComparisons } from './property-matching'
@@ -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[]
@@ -0,0 +1,26 @@
1
+ import { V7Generator } from './uuidv7'
2
+
3
+ describe('uuidv7 default RNG', () => {
4
+ const realRandom = Math.random
5
+
6
+ afterEach(() => {
7
+ Math.random = realRandom
8
+ })
9
+
10
+ it.each([
11
+ ['exactly 1.0', 1.0],
12
+ ['greater than 1', 1.5],
13
+ ['NaN', NaN],
14
+ ])('does not throw when Math.random() returns %s', (_label, value) => {
15
+ Math.random = () => value
16
+ const generator = new V7Generator()
17
+ const uuid = generator.generate().toString()
18
+ expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
19
+ })
20
+
21
+ it('still generates well-formed v7 UUIDs with the real Math.random', () => {
22
+ const generator = new V7Generator()
23
+ const uuid = generator.generate().toString()
24
+ expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
25
+ })
26
+ })
@@ -429,9 +429,15 @@ const getDefaultRandom = (): { nextUint32(): number } => {
429
429
  // };
430
430
  // }
431
431
  return {
432
+ // Clamp to a valid uint32: a nonconformant Math.random() that returns >= 1 or NaN
433
+ // (e.g. Hermes on Android implements Math.random with C++ std::uniform_real_distribution,
434
+ // which is documented to occasionally return its upper bound) would otherwise overflow the
435
+ // field ranges and make fromFieldsV7 throw `RangeError: invalid field value` on every
436
+ // generate call, crashing React Native apps during the internal event-queue flush.
432
437
  nextUint32: (): number =>
433
- Math.trunc(Math.random() * 0x1_0000) * 0x1_0000 +
434
- Math.trunc(Math.random() * 0x1_0000),
438
+ (Math.trunc(Math.random() * 0x1_0000) * 0x1_0000 +
439
+ Math.trunc(Math.random() * 0x1_0000)) >>>
440
+ 0,
435
441
  };
436
442
  };
437
443