@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.
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,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
+ }
@@ -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
- const component = { lib: { getFeatureFlag: 'getFeatureFlag_ref' } }
645
- const posthog = new PostHog(component as never, {
646
- apiKey: 'key',
647
- identify: identifyReturning('auth-user'),
648
- })
649
- const ctx = {
650
- runAction: jest.fn(async (_ref: unknown, _args: Record<string, unknown>) => true),
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 () => {