@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,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
|
+
})
|