@posthog/convex 0.2.33 → 1.0.1
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/README.md +132 -16
- package/dist/client/feature-flags/crypto.d.ts +2 -0
- package/dist/client/feature-flags/crypto.d.ts.map +1 -0
- package/dist/client/feature-flags/crypto.js +11 -0
- package/dist/client/feature-flags/crypto.js.map +1 -0
- package/dist/client/feature-flags/evaluator.d.ts +47 -0
- package/dist/client/feature-flags/evaluator.d.ts.map +1 -0
- package/dist/client/feature-flags/evaluator.js +346 -0
- package/dist/client/feature-flags/evaluator.js.map +1 -0
- package/dist/client/feature-flags/index.d.ts +4 -0
- package/dist/client/feature-flags/index.d.ts.map +1 -0
- package/dist/client/feature-flags/index.js +3 -0
- package/dist/client/feature-flags/index.js.map +1 -0
- package/dist/client/feature-flags/match-property.d.ts +12 -0
- package/dist/client/feature-flags/match-property.d.ts.map +1 -0
- package/dist/client/feature-flags/match-property.js +340 -0
- package/dist/client/feature-flags/match-property.js.map +1 -0
- package/dist/client/feature-flags/types.d.ts +63 -0
- package/dist/client/feature-flags/types.d.ts.map +1 -0
- package/dist/client/feature-flags/types.js +2 -0
- package/dist/client/feature-flags/types.js.map +1 -0
- package/dist/client/index.d.ts +71 -36
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +143 -32
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +8 -35
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/lib.d.ts +76 -46
- package/dist/component/lib.d.ts.map +1 -1
- package/dist/component/lib.js +311 -99
- package/dist/component/lib.js.map +1 -1
- package/dist/component/schema.d.ts +18 -1
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +16 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/component/version.d.ts +2 -0
- package/dist/component/version.d.ts.map +1 -0
- package/dist/component/version.js +2 -0
- package/dist/component/version.js.map +1 -0
- package/package.json +5 -5
- package/src/client/feature-flags/crypto.ts +12 -0
- package/src/client/feature-flags/evaluator.test.ts +401 -0
- package/src/client/feature-flags/evaluator.ts +467 -0
- package/src/client/feature-flags/index.ts +15 -0
- package/src/client/feature-flags/match-property.test.ts +75 -0
- package/src/client/feature-flags/match-property.ts +347 -0
- package/src/client/feature-flags/types.ts +72 -0
- package/src/client/index.test.ts +60 -12
- package/src/client/index.ts +227 -70
- package/src/component/_generated/component.ts +7 -50
- package/src/component/lib.ts +340 -127
- package/src/component/schema.ts +16 -2
- package/src/component/version.ts +1 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import type { FlagProperty, FlagPropertyValue, PropertyGroup } from './types.js'
|
|
2
|
+
|
|
3
|
+
// Operators that should still run their switch case when the property value is null/undefined.
|
|
4
|
+
// `is_not` may legitimately compare against null; `is_set` only cares about key presence and
|
|
5
|
+
// must not be short-circuited by the null guard below.
|
|
6
|
+
const NULL_VALUES_ALLOWED_OPERATORS = ['is_not', 'is_set']
|
|
7
|
+
|
|
8
|
+
export class InconclusiveMatchError extends Error {
|
|
9
|
+
constructor(message: string) {
|
|
10
|
+
super(message)
|
|
11
|
+
this.name = this.constructor.name
|
|
12
|
+
Object.setPrototypeOf(this, InconclusiveMatchError.prototype)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class RequiresServerEvaluation extends Error {
|
|
17
|
+
constructor(message: string) {
|
|
18
|
+
super(message)
|
|
19
|
+
this.name = this.constructor.name
|
|
20
|
+
Object.setPrototypeOf(this, RequiresServerEvaluation.prototype)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isValidRegex(regex: string): boolean {
|
|
25
|
+
try {
|
|
26
|
+
new RegExp(regex)
|
|
27
|
+
return true
|
|
28
|
+
} catch {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type SemverTuple = [number, number, number]
|
|
34
|
+
|
|
35
|
+
function parseSemver(value: string): SemverTuple {
|
|
36
|
+
const text = String(value).trim().replace(/^[vV]/, '')
|
|
37
|
+
const baseVersion = text.split('-')[0].split('+')[0]
|
|
38
|
+
|
|
39
|
+
if (!baseVersion || baseVersion.startsWith('.')) {
|
|
40
|
+
throw new InconclusiveMatchError(`Invalid semver: ${value}`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const parts = baseVersion.split('.')
|
|
44
|
+
|
|
45
|
+
const parsePart = (part: string | undefined): number => {
|
|
46
|
+
if (part === undefined || part === '') return 0
|
|
47
|
+
if (!/^\d+$/.test(part)) {
|
|
48
|
+
throw new InconclusiveMatchError(`Invalid semver: ${value}`)
|
|
49
|
+
}
|
|
50
|
+
return parseInt(part, 10)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return [parsePart(parts[0]), parsePart(parts[1]), parsePart(parts[2])]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function compareSemverTuples(a: SemverTuple, b: SemverTuple): number {
|
|
57
|
+
for (let i = 0; i < 3; i++) {
|
|
58
|
+
if (a[i] < b[i]) return -1
|
|
59
|
+
if (a[i] > b[i]) return 1
|
|
60
|
+
}
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function computeTildeBounds(value: string): { lower: SemverTuple; upper: SemverTuple } {
|
|
65
|
+
const parsed = parseSemver(value)
|
|
66
|
+
return { lower: [parsed[0], parsed[1], parsed[2]], upper: [parsed[0], parsed[1] + 1, 0] }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function computeCaretBounds(value: string): { lower: SemverTuple; upper: SemverTuple } {
|
|
70
|
+
const [major, minor, patch] = parseSemver(value)
|
|
71
|
+
const lower: SemverTuple = [major, minor, patch]
|
|
72
|
+
let upper: SemverTuple
|
|
73
|
+
if (major > 0) upper = [major + 1, 0, 0]
|
|
74
|
+
else if (minor > 0) upper = [0, minor + 1, 0]
|
|
75
|
+
else upper = [0, 0, patch + 1]
|
|
76
|
+
return { lower, upper }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function computeWildcardBounds(value: string): { lower: SemverTuple; upper: SemverTuple } {
|
|
80
|
+
const text = String(value).trim().replace(/^[vV]/, '')
|
|
81
|
+
const cleanedText = text.replace(/\.\*$/, '').replace(/\*$/, '')
|
|
82
|
+
if (!cleanedText) throw new InconclusiveMatchError(`Invalid wildcard semver: ${value}`)
|
|
83
|
+
|
|
84
|
+
const parts = cleanedText.split('.')
|
|
85
|
+
const major = parseInt(parts[0], 10)
|
|
86
|
+
if (isNaN(major)) throw new InconclusiveMatchError(`Invalid wildcard semver: ${value}`)
|
|
87
|
+
|
|
88
|
+
if (parts.length === 1) {
|
|
89
|
+
return { lower: [major, 0, 0], upper: [major + 1, 0, 0] }
|
|
90
|
+
}
|
|
91
|
+
const minor = parseInt(parts[1], 10)
|
|
92
|
+
if (isNaN(minor)) throw new InconclusiveMatchError(`Invalid wildcard semver: ${value}`)
|
|
93
|
+
return { lower: [major, minor, 0], upper: [major, minor + 1, 0] }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function convertToDateTime(value: FlagPropertyValue | Date): Date {
|
|
97
|
+
if (value instanceof Date) return value
|
|
98
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
99
|
+
const date = new Date(value)
|
|
100
|
+
if (!isNaN(date.valueOf())) return date
|
|
101
|
+
throw new InconclusiveMatchError(`${value} is in an invalid date format`)
|
|
102
|
+
}
|
|
103
|
+
throw new InconclusiveMatchError(`The date provided ${value} must be a string, number, or date object`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function relativeDateParseForFeatureFlagMatching(value: string): Date | null {
|
|
107
|
+
const regex = /^-?(?<number>[0-9]+)(?<interval>[a-z])$/
|
|
108
|
+
const match = value.match(regex)
|
|
109
|
+
const parsedDt = new Date(new Date().toISOString())
|
|
110
|
+
|
|
111
|
+
if (!match || !match.groups) return null
|
|
112
|
+
|
|
113
|
+
const number = parseInt(match.groups['number'])
|
|
114
|
+
if (number >= 10000) return null
|
|
115
|
+
|
|
116
|
+
const interval = match.groups['interval']
|
|
117
|
+
if (interval == 'h') parsedDt.setUTCHours(parsedDt.getUTCHours() - number)
|
|
118
|
+
else if (interval == 'd') parsedDt.setUTCDate(parsedDt.getUTCDate() - number)
|
|
119
|
+
else if (interval == 'w') parsedDt.setUTCDate(parsedDt.getUTCDate() - number * 7)
|
|
120
|
+
else if (interval == 'm') parsedDt.setUTCMonth(parsedDt.getUTCMonth() - number)
|
|
121
|
+
else if (interval == 'y') parsedDt.setUTCFullYear(parsedDt.getUTCFullYear() - number)
|
|
122
|
+
else return null
|
|
123
|
+
|
|
124
|
+
return parsedDt
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function matchProperty(
|
|
128
|
+
property: FlagProperty,
|
|
129
|
+
propertyValues: Record<string, any>,
|
|
130
|
+
warnFunction?: (msg: string) => void
|
|
131
|
+
): boolean {
|
|
132
|
+
const key = property.key
|
|
133
|
+
const value = property.value
|
|
134
|
+
const operator = property.operator || 'exact'
|
|
135
|
+
|
|
136
|
+
if (!(key in propertyValues)) {
|
|
137
|
+
// When the property is genuinely absent we can answer `is_not_set` locally — no need to
|
|
138
|
+
// bail out as inconclusive and force the flag to return undefined.
|
|
139
|
+
if (operator === 'is_not_set') return true
|
|
140
|
+
throw new InconclusiveMatchError(`Property ${key} not found in propertyValues`)
|
|
141
|
+
} else if (operator === 'is_not_set') {
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const overrideValue = propertyValues[key]
|
|
146
|
+
if (overrideValue == null && !NULL_VALUES_ALLOWED_OPERATORS.includes(operator)) {
|
|
147
|
+
warnFunction?.(`Property ${key} cannot have a value of null/undefined with the ${operator} operator`)
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function computeExactMatch(value: any, overrideValue: any): boolean {
|
|
152
|
+
if (Array.isArray(value)) {
|
|
153
|
+
return value.map((val) => String(val).toLowerCase()).includes(String(overrideValue).toLowerCase())
|
|
154
|
+
}
|
|
155
|
+
return String(value).toLowerCase() === String(overrideValue).toLowerCase()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function compare(lhs: any, rhs: any, op: string): boolean {
|
|
159
|
+
if (op === 'gt') return lhs > rhs
|
|
160
|
+
if (op === 'gte') return lhs >= rhs
|
|
161
|
+
if (op === 'lt') return lhs < rhs
|
|
162
|
+
if (op === 'lte') return lhs <= rhs
|
|
163
|
+
throw new Error(`Invalid operator: ${op}`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
switch (operator) {
|
|
167
|
+
case 'exact':
|
|
168
|
+
return computeExactMatch(value, overrideValue)
|
|
169
|
+
case 'is_not':
|
|
170
|
+
return !computeExactMatch(value, overrideValue)
|
|
171
|
+
case 'is_set':
|
|
172
|
+
return key in propertyValues
|
|
173
|
+
case 'icontains':
|
|
174
|
+
return String(overrideValue).toLowerCase().includes(String(value).toLowerCase())
|
|
175
|
+
case 'not_icontains':
|
|
176
|
+
return !String(overrideValue).toLowerCase().includes(String(value).toLowerCase())
|
|
177
|
+
case 'regex':
|
|
178
|
+
return isValidRegex(String(value)) && String(overrideValue).match(String(value)) !== null
|
|
179
|
+
case 'not_regex':
|
|
180
|
+
return isValidRegex(String(value)) && String(overrideValue).match(String(value)) === null
|
|
181
|
+
case 'gt':
|
|
182
|
+
case 'gte':
|
|
183
|
+
case 'lt':
|
|
184
|
+
case 'lte': {
|
|
185
|
+
// Try a numeric comparison first; only fall back to lexicographic when one side genuinely
|
|
186
|
+
// isn't a number. `parseFloat` returns NaN for non-numeric strings, so `Number.isFinite`
|
|
187
|
+
// is the right guard — `NaN != null` would slip through and produce nonsense comparisons
|
|
188
|
+
// like `NaN > 5`. Likewise, when a person property arrives as the string `"10"` we want
|
|
189
|
+
// `"10" > "9"` to evaluate numerically (true), not lexicographically (false).
|
|
190
|
+
const parsedValue = typeof value === 'number' ? value : parseFloat(String(value))
|
|
191
|
+
const parsedOverride =
|
|
192
|
+
typeof overrideValue === 'number'
|
|
193
|
+
? overrideValue
|
|
194
|
+
: overrideValue != null
|
|
195
|
+
? parseFloat(String(overrideValue))
|
|
196
|
+
: NaN
|
|
197
|
+
if (Number.isFinite(parsedValue) && Number.isFinite(parsedOverride)) {
|
|
198
|
+
return compare(parsedOverride, parsedValue, operator)
|
|
199
|
+
}
|
|
200
|
+
return compare(String(overrideValue), String(value), operator)
|
|
201
|
+
}
|
|
202
|
+
case 'is_date_after':
|
|
203
|
+
case 'is_date_before': {
|
|
204
|
+
if (typeof value === 'boolean') {
|
|
205
|
+
throw new InconclusiveMatchError(`Date operations cannot be performed on boolean values`)
|
|
206
|
+
}
|
|
207
|
+
let parsedDate = relativeDateParseForFeatureFlagMatching(String(value))
|
|
208
|
+
if (parsedDate == null) parsedDate = convertToDateTime(value)
|
|
209
|
+
if (parsedDate == null) throw new InconclusiveMatchError(`Invalid date: ${value}`)
|
|
210
|
+
const overrideDate = convertToDateTime(overrideValue)
|
|
211
|
+
if (operator === 'is_date_before') return overrideDate < parsedDate
|
|
212
|
+
return overrideDate > parsedDate
|
|
213
|
+
}
|
|
214
|
+
case 'semver_eq':
|
|
215
|
+
return compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value))) === 0
|
|
216
|
+
case 'semver_neq':
|
|
217
|
+
return compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value))) !== 0
|
|
218
|
+
case 'semver_gt':
|
|
219
|
+
return compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value))) > 0
|
|
220
|
+
case 'semver_gte':
|
|
221
|
+
return compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value))) >= 0
|
|
222
|
+
case 'semver_lt':
|
|
223
|
+
return compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value))) < 0
|
|
224
|
+
case 'semver_lte':
|
|
225
|
+
return compareSemverTuples(parseSemver(String(overrideValue)), parseSemver(String(value))) <= 0
|
|
226
|
+
case 'semver_tilde': {
|
|
227
|
+
const overrideParsed = parseSemver(String(overrideValue))
|
|
228
|
+
const { lower, upper } = computeTildeBounds(String(value))
|
|
229
|
+
return compareSemverTuples(overrideParsed, lower) >= 0 && compareSemverTuples(overrideParsed, upper) < 0
|
|
230
|
+
}
|
|
231
|
+
case 'semver_caret': {
|
|
232
|
+
const overrideParsed = parseSemver(String(overrideValue))
|
|
233
|
+
const { lower, upper } = computeCaretBounds(String(value))
|
|
234
|
+
return compareSemverTuples(overrideParsed, lower) >= 0 && compareSemverTuples(overrideParsed, upper) < 0
|
|
235
|
+
}
|
|
236
|
+
case 'semver_wildcard': {
|
|
237
|
+
const overrideParsed = parseSemver(String(overrideValue))
|
|
238
|
+
const { lower, upper } = computeWildcardBounds(String(value))
|
|
239
|
+
return compareSemverTuples(overrideParsed, lower) >= 0 && compareSemverTuples(overrideParsed, upper) < 0
|
|
240
|
+
}
|
|
241
|
+
default:
|
|
242
|
+
throw new InconclusiveMatchError(`Unknown operator: ${operator}`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function matchCohort(
|
|
247
|
+
property: FlagProperty,
|
|
248
|
+
propertyValues: Record<string, any>,
|
|
249
|
+
cohortProperties: Record<string, PropertyGroup>,
|
|
250
|
+
debugMode: boolean = false
|
|
251
|
+
): boolean {
|
|
252
|
+
const cohortId = String(property.value)
|
|
253
|
+
if (!(cohortId in cohortProperties)) {
|
|
254
|
+
throw new RequiresServerEvaluation(
|
|
255
|
+
`cohort ${cohortId} not found in local cohorts - likely a static cohort that requires server evaluation`
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
return matchPropertyGroup(cohortProperties[cohortId], propertyValues, cohortProperties, debugMode)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function matchPropertyGroup(
|
|
262
|
+
propertyGroup: PropertyGroup,
|
|
263
|
+
propertyValues: Record<string, any>,
|
|
264
|
+
cohortProperties: Record<string, PropertyGroup>,
|
|
265
|
+
debugMode: boolean = false
|
|
266
|
+
): boolean {
|
|
267
|
+
if (!propertyGroup) return true
|
|
268
|
+
|
|
269
|
+
const propertyGroupType = propertyGroup.type
|
|
270
|
+
const properties = propertyGroup.values
|
|
271
|
+
|
|
272
|
+
if (!properties || properties.length === 0) return true
|
|
273
|
+
|
|
274
|
+
let errorMatchingLocally = false
|
|
275
|
+
|
|
276
|
+
if ('values' in properties[0]) {
|
|
277
|
+
for (const prop of properties as PropertyGroup[]) {
|
|
278
|
+
try {
|
|
279
|
+
const matches = matchPropertyGroup(prop, propertyValues, cohortProperties, debugMode)
|
|
280
|
+
if (propertyGroupType === 'AND') {
|
|
281
|
+
if (!matches) return false
|
|
282
|
+
} else {
|
|
283
|
+
if (matches) return true
|
|
284
|
+
}
|
|
285
|
+
} catch (err) {
|
|
286
|
+
if (err instanceof RequiresServerEvaluation) throw err
|
|
287
|
+
if (err instanceof InconclusiveMatchError) {
|
|
288
|
+
if (debugMode) console.debug(`Failed to compute property ${prop} locally: ${err}`)
|
|
289
|
+
errorMatchingLocally = true
|
|
290
|
+
} else {
|
|
291
|
+
throw err
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (errorMatchingLocally) {
|
|
297
|
+
throw new InconclusiveMatchError("Can't match cohort without a given cohort property value")
|
|
298
|
+
}
|
|
299
|
+
return propertyGroupType === 'AND'
|
|
300
|
+
} else {
|
|
301
|
+
for (const prop of properties as FlagProperty[]) {
|
|
302
|
+
try {
|
|
303
|
+
let matches: boolean
|
|
304
|
+
if (prop.type === 'cohort') {
|
|
305
|
+
matches = matchCohort(prop, propertyValues, cohortProperties, debugMode)
|
|
306
|
+
} else if (prop.type === 'flag') {
|
|
307
|
+
if (debugMode) {
|
|
308
|
+
console.warn(
|
|
309
|
+
`[FEATURE FLAGS] Flag dependency filters are not supported in local evaluation. ` +
|
|
310
|
+
`Skipping condition with dependency on flag '${prop.key || 'unknown'}'`
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
// Mark the group as inconclusive so we don't silently grant cohort membership in an AND
|
|
314
|
+
// group whose missing flag dependency would have evaluated to false (or deny it in an OR
|
|
315
|
+
// group whose flag dependency would have matched). Falls through to the
|
|
316
|
+
// InconclusiveMatchError throw at the end of the loop.
|
|
317
|
+
errorMatchingLocally = true
|
|
318
|
+
continue
|
|
319
|
+
} else {
|
|
320
|
+
matches = matchProperty(prop, propertyValues)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const negation = prop.negation || false
|
|
324
|
+
if (propertyGroupType === 'AND') {
|
|
325
|
+
if (!matches && !negation) return false
|
|
326
|
+
if (matches && negation) return false
|
|
327
|
+
} else {
|
|
328
|
+
if (matches && !negation) return true
|
|
329
|
+
if (!matches && negation) return true
|
|
330
|
+
}
|
|
331
|
+
} catch (err) {
|
|
332
|
+
if (err instanceof RequiresServerEvaluation) throw err
|
|
333
|
+
if (err instanceof InconclusiveMatchError) {
|
|
334
|
+
if (debugMode) console.debug(`Failed to compute property ${prop} locally: ${err}`)
|
|
335
|
+
errorMatchingLocally = true
|
|
336
|
+
} else {
|
|
337
|
+
throw err
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (errorMatchingLocally) {
|
|
343
|
+
throw new InconclusiveMatchError("can't match cohort without a given cohort property value")
|
|
344
|
+
}
|
|
345
|
+
return propertyGroupType === 'AND'
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { FeatureFlagValue, JsonType } from '@posthog/core'
|
|
2
|
+
|
|
3
|
+
export type { FeatureFlagValue, JsonType }
|
|
4
|
+
|
|
5
|
+
export type FlagPropertyValue = string | number | (string | number)[] | boolean
|
|
6
|
+
|
|
7
|
+
export type FlagProperty = {
|
|
8
|
+
key: string
|
|
9
|
+
type?: string
|
|
10
|
+
value: FlagPropertyValue
|
|
11
|
+
operator?: string
|
|
12
|
+
negation?: boolean
|
|
13
|
+
dependency_chain?: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type PropertyGroup = {
|
|
17
|
+
type: 'AND' | 'OR'
|
|
18
|
+
values: PropertyGroup[] | FlagProperty[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type FeatureFlagCondition = {
|
|
22
|
+
properties: FlagProperty[]
|
|
23
|
+
rollout_percentage?: number
|
|
24
|
+
variant?: string
|
|
25
|
+
aggregation_group_type_index?: number | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type FeatureFlagBucketingIdentifier = 'distinct_id' | 'device_id' | '' | null
|
|
29
|
+
|
|
30
|
+
export type PostHogFeatureFlag = {
|
|
31
|
+
id: number
|
|
32
|
+
name: string
|
|
33
|
+
key: string
|
|
34
|
+
bucketing_identifier?: FeatureFlagBucketingIdentifier
|
|
35
|
+
filters?: {
|
|
36
|
+
aggregation_group_type_index?: number
|
|
37
|
+
groups?: FeatureFlagCondition[]
|
|
38
|
+
multivariate?: {
|
|
39
|
+
variants: {
|
|
40
|
+
key: string
|
|
41
|
+
rollout_percentage: number
|
|
42
|
+
}[]
|
|
43
|
+
}
|
|
44
|
+
payloads?: Record<string, string>
|
|
45
|
+
}
|
|
46
|
+
deleted: boolean
|
|
47
|
+
active: boolean
|
|
48
|
+
rollout_percentage: null | number
|
|
49
|
+
ensure_experience_continuity: boolean
|
|
50
|
+
experiment_set: number[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type FlagDefinitions = {
|
|
54
|
+
flags: PostHogFeatureFlag[]
|
|
55
|
+
groupTypeMapping: Record<string, string>
|
|
56
|
+
cohorts: Record<string, PropertyGroup>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type FeatureFlagEvaluationContext = {
|
|
60
|
+
distinctId: string
|
|
61
|
+
groups: Record<string, string>
|
|
62
|
+
personProperties: Record<string, any>
|
|
63
|
+
groupProperties: Record<string, Record<string, any>>
|
|
64
|
+
evaluationCache: Record<string, FeatureFlagValue>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type FeatureFlagResult = {
|
|
68
|
+
key: string
|
|
69
|
+
enabled: boolean
|
|
70
|
+
variant: string | null
|
|
71
|
+
payload: JsonType | null
|
|
72
|
+
}
|
package/src/client/index.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test, jest } from '@jest/globals'
|
|
2
2
|
import { PostHog, normalizeError } from './index.js'
|
|
3
3
|
import type { BeforeSendFn, IdentifyFn } from './index.js'
|
|
4
|
+
import { LocalFeatureFlagEvaluator } from './feature-flags/index.js'
|
|
4
5
|
|
|
5
6
|
function mockSchedulerCtx() {
|
|
6
7
|
return {
|
|
@@ -78,6 +79,40 @@ describe('PostHog client', () => {
|
|
|
78
79
|
expect(typeof posthog.getAllFlags).toBe('function')
|
|
79
80
|
expect(typeof posthog.getAllFlagsAndPayloads).toBe('function')
|
|
80
81
|
})
|
|
82
|
+
|
|
83
|
+
test('getFeatureFlagPayload with matchValue does not require a distinctId', async () => {
|
|
84
|
+
// The matchValue path is a pure key+value payload lookup; resolving a distinctId would
|
|
85
|
+
// force callers to configure an identify callback or pass an ID they don't have.
|
|
86
|
+
const definitions = JSON.stringify({
|
|
87
|
+
flags: [
|
|
88
|
+
{
|
|
89
|
+
id: 1,
|
|
90
|
+
name: 'flag',
|
|
91
|
+
key: 'flag',
|
|
92
|
+
deleted: false,
|
|
93
|
+
active: true,
|
|
94
|
+
rollout_percentage: null,
|
|
95
|
+
ensure_experience_continuity: false,
|
|
96
|
+
experiment_set: [],
|
|
97
|
+
filters: {
|
|
98
|
+
groups: [{ properties: [], rollout_percentage: 0 }],
|
|
99
|
+
multivariate: { variants: [{ key: 'red', rollout_percentage: 100 }] },
|
|
100
|
+
payloads: { red: 'red-payload' },
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
groupTypeMapping: {},
|
|
105
|
+
cohorts: {},
|
|
106
|
+
})
|
|
107
|
+
const component = { lib: { getFlagDefinitions: 'getFlagDefinitions_ref' } }
|
|
108
|
+
const posthog = new PostHog(component as never, { apiKey: 'key' })
|
|
109
|
+
const ctx = {
|
|
110
|
+
runQuery: jest.fn(async () => ({ data: definitions, fetchedAt: Date.now() })),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const payload = await posthog.getFeatureFlagPayload(ctx as never, { key: 'flag', matchValue: 'red' })
|
|
114
|
+
expect(payload).toBe('red-payload')
|
|
115
|
+
})
|
|
81
116
|
})
|
|
82
117
|
|
|
83
118
|
describe('normalizeError', () => {
|
|
@@ -641,19 +676,32 @@ describe('identify callback', () => {
|
|
|
641
676
|
})
|
|
642
677
|
|
|
643
678
|
test('works with feature flag methods', async () => {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
679
|
+
// Spy on the evaluator's `getFeatureFlag` so we can assert that the distinctId resolved by
|
|
680
|
+
// the identify callback ('auth-user') is the one actually forwarded to evaluation.
|
|
681
|
+
const evalSpy = jest.spyOn(LocalFeatureFlagEvaluator.prototype, 'getFeatureFlag').mockResolvedValue(true)
|
|
682
|
+
try {
|
|
683
|
+
const component = { lib: { getFlagDefinitions: 'getFlagDefinitions_ref' } }
|
|
684
|
+
const posthog = new PostHog(component as never, {
|
|
685
|
+
apiKey: 'key',
|
|
686
|
+
identify: identifyReturning('auth-user'),
|
|
687
|
+
})
|
|
688
|
+
// Stub real-looking flag definitions so `loadEvaluator` returns an instance.
|
|
689
|
+
const definitions = {
|
|
690
|
+
data: JSON.stringify({ flags: [], groupTypeMapping: {}, cohorts: {} }),
|
|
691
|
+
fetchedAt: Date.now(),
|
|
692
|
+
}
|
|
693
|
+
const runQuery = jest.fn(async () => definitions)
|
|
694
|
+
const ctx = { runQuery }
|
|
695
|
+
|
|
696
|
+
await posthog.getFeatureFlag(ctx as never, { key: 'my-flag' })
|
|
697
|
+
|
|
698
|
+
expect(runQuery).toHaveBeenCalledWith('getFlagDefinitions_ref', {})
|
|
699
|
+
// The evaluator's getFeatureFlag(key, distinctId, groups, personProps, groupProps) — assert
|
|
700
|
+
// we pass the auth-resolved id straight through.
|
|
701
|
+
expect(evalSpy).toHaveBeenCalledWith('my-flag', 'auth-user', {}, {}, {})
|
|
702
|
+
} finally {
|
|
703
|
+
evalSpy.mockRestore()
|
|
651
704
|
}
|
|
652
|
-
|
|
653
|
-
await posthog.getFeatureFlag(ctx as never, { key: 'my-flag' })
|
|
654
|
-
|
|
655
|
-
const [, args] = ctx.runAction.mock.calls[0]
|
|
656
|
-
expect(args.distinctId).toBe('auth-user')
|
|
657
705
|
})
|
|
658
706
|
|
|
659
707
|
test('explicit distinctId still works without identify callback', async () => {
|