@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
@@ -1,10 +1,42 @@
1
- import type { FeatureFlagValue, JsonType } from '@posthog/core'
2
- import { PostHog } from 'posthog-node/edge'
3
- import { action } from './_generated/server.js'
1
+ import { PostHog as PostHogEdge } from 'posthog-node/edge'
2
+ import { action, internalMutation, internalQuery, query } from './_generated/server.js'
3
+ import { api, internal } from './_generated/api.js'
4
4
  import { v } from 'convex/values'
5
+ import { version } from './version.js'
5
6
 
6
- function createClient(apiKey: string, host: string) {
7
- return new PostHog(apiKey, { host, flushAt: 1, flushInterval: 0 })
7
+ /**
8
+ * Brand events sent through this component as `posthog-convex` rather than `posthog-edge` in the
9
+ * `$lib` / `$lib_version` properties — makes them filterable in PostHog and lets us attribute
10
+ * issues to the integration vs. raw `posthog-node` usage.
11
+ */
12
+ class PostHog extends PostHogEdge {
13
+ getLibraryId(): string {
14
+ return 'posthog-convex'
15
+ }
16
+ getLibraryVersion(): string {
17
+ return version
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Cache PostHog clients across action invocations within the same Convex isolate.
23
+ *
24
+ * Convex reuses JS isolates between invocations, so module-level state survives. Constructing
25
+ * a fresh client per call (and tearing it down with `shutdown()`) is wasted work — the client
26
+ * carries no per-invocation state once `flush()` has drained its queue.
27
+ *
28
+ * Keyed by `apiKey|host` to support the rare case of multiple credentials sharing one isolate.
29
+ */
30
+ const clientCache = new Map<string, PostHog>()
31
+
32
+ function getClient(apiKey: string, host: string): PostHog {
33
+ const key = `${apiKey}|${host}`
34
+ let client = clientCache.get(key)
35
+ if (!client) {
36
+ client = new PostHog(apiKey, { host, flushAt: 1, flushInterval: 0 })
37
+ clientCache.set(key, client)
38
+ }
39
+ return client
8
40
  }
9
41
 
10
42
  /** Properties are JSON-serialized to bypass Convex's restriction on `$`-prefixed field names. */
@@ -32,8 +64,8 @@ export const capture = action({
32
64
  disableGeoip: v.optional(v.boolean()),
33
65
  },
34
66
  handler: async (_ctx, args) => {
35
- const client = createClient(args.apiKey, args.host)
36
- client.capture({
67
+ const client = getClient(args.apiKey, args.host)
68
+ await client.captureImmediate({
37
69
  distinctId: args.distinctId,
38
70
  event: args.event,
39
71
  properties: parseProperties(args.properties),
@@ -43,7 +75,6 @@ export const capture = action({
43
75
  uuid: args.uuid,
44
76
  disableGeoip: args.disableGeoip,
45
77
  })
46
- await client.shutdown()
47
78
  },
48
79
  })
49
80
 
@@ -56,13 +87,27 @@ export const identify = action({
56
87
  disableGeoip: v.optional(v.boolean()),
57
88
  },
58
89
  handler: async (_ctx, args) => {
59
- const client = createClient(args.apiKey, args.host)
60
- client.identify({
90
+ const client = getClient(args.apiKey, args.host)
91
+ // posthog-node's `identifyImmediate` is missing an `await` on `identifyStatelessImmediate`
92
+ // (packages/node/src/client.ts:674), so the returned promise resolves before the event hits
93
+ // the wire. We sidestep that by composing the `$identify` event the same way `identifyImmediate`
94
+ // does and routing it through `captureImmediate`, which awaits correctly.
95
+ const properties = parseProperties(args.properties) ?? {}
96
+ const { $set, $set_once, $anon_distinct_id, ...rest } = properties as {
97
+ $set?: Record<string, unknown>
98
+ $set_once?: Record<string, unknown>
99
+ $anon_distinct_id?: string
100
+ } & Record<string, unknown>
101
+ await client.captureImmediate({
61
102
  distinctId: args.distinctId,
62
- properties: parseProperties(args.properties),
103
+ event: '$identify',
104
+ properties: {
105
+ $set: $set ?? rest,
106
+ $set_once: $set_once ?? {},
107
+ $anon_distinct_id,
108
+ },
63
109
  disableGeoip: args.disableGeoip,
64
110
  })
65
- await client.shutdown()
66
111
  },
67
112
  })
68
113
 
@@ -77,15 +122,20 @@ export const groupIdentify = action({
77
122
  disableGeoip: v.optional(v.boolean()),
78
123
  },
79
124
  handler: async (_ctx, args) => {
80
- const client = createClient(args.apiKey, args.host)
81
- client.groupIdentify({
82
- groupType: args.groupType,
83
- groupKey: args.groupKey,
84
- properties: parseProperties(args.properties),
85
- distinctId: args.distinctId,
125
+ const client = getClient(args.apiKey, args.host)
126
+ // posthog-node doesn't expose a `groupIdentifyImmediate`, so we send the same `$groupidentify`
127
+ // event via `captureImmediate` to keep parity with capture/identify/alias/captureException —
128
+ // resolve when the network call completes, without resorting to shutdown().
129
+ await client.captureImmediate({
130
+ distinctId: args.distinctId || `$${args.groupType}_${args.groupKey}`,
131
+ event: '$groupidentify',
132
+ properties: {
133
+ $group_type: args.groupType,
134
+ $group_key: args.groupKey,
135
+ $group_set: parseProperties(args.properties) ?? {},
136
+ },
86
137
  disableGeoip: args.disableGeoip,
87
138
  })
88
- await client.shutdown()
89
139
  },
90
140
  })
91
141
 
@@ -98,13 +148,12 @@ export const alias = action({
98
148
  disableGeoip: v.optional(v.boolean()),
99
149
  },
100
150
  handler: async (_ctx, args) => {
101
- const client = createClient(args.apiKey, args.host)
102
- client.alias({
151
+ const client = getClient(args.apiKey, args.host)
152
+ await client.aliasImmediate({
103
153
  distinctId: args.distinctId,
104
154
  alias: args.alias,
105
155
  disableGeoip: args.disableGeoip,
106
156
  })
107
- await client.shutdown()
108
157
  },
109
158
  })
110
159
 
@@ -119,152 +168,316 @@ export const captureException = action({
119
168
  additionalProperties: v.optional(v.string()),
120
169
  },
121
170
  handler: async (_ctx, args) => {
122
- const client = createClient(args.apiKey, args.host)
171
+ const client = getClient(args.apiKey, args.host)
123
172
  const error = new Error(args.errorMessage)
124
173
  if (args.errorName) error.name = args.errorName
125
174
  if (args.errorStack) error.stack = args.errorStack
126
- client.captureException(error, args.distinctId, parseProperties(args.additionalProperties))
127
- await client.shutdown()
175
+ await client.captureExceptionImmediate(error, args.distinctId, parseProperties(args.additionalProperties))
128
176
  },
129
177
  })
130
178
 
131
- // Feature flag actions these return values and must be called via ctx.runAction
179
+ // --- Feature flag remote evaluation ---
180
+ //
181
+ // These actions hit PostHog's `/flags` endpoint directly via `posthog-node`. Use them when
182
+ // local evaluation isn't available (no personal API key) or can't reach a verdict (experience
183
+ // continuity flags, static cohorts, properties you don't have in your server context). They
184
+ // require an action context — that's the trade for not needing flag definitions cached upfront.
132
185
 
133
- const featureFlagArgs = {
186
+ const remoteFlagsArgs = {
134
187
  apiKey: v.string(),
135
188
  host: v.string(),
136
- key: v.string(),
137
189
  distinctId: v.string(),
138
190
  groups: v.optional(v.any()),
139
191
  personProperties: v.optional(v.any()),
140
192
  groupProperties: v.optional(v.any()),
141
- sendFeatureFlagEvents: v.optional(v.boolean()),
142
193
  disableGeoip: v.optional(v.boolean()),
194
+ flagKeys: v.optional(v.array(v.string())),
143
195
  }
144
196
 
145
- function featureFlagOptions(args: {
146
- groups?: Record<string, string>
147
- personProperties?: Record<string, string>
148
- groupProperties?: Record<string, Record<string, string>>
149
- sendFeatureFlagEvents?: boolean
197
+ function remoteFlagsOptions(args: {
198
+ groups?: unknown
199
+ personProperties?: unknown
200
+ groupProperties?: unknown
150
201
  disableGeoip?: boolean
202
+ flagKeys?: string[]
151
203
  }) {
152
204
  return {
153
- groups: args.groups,
154
- personProperties: args.personProperties,
155
- groupProperties: args.groupProperties,
156
- sendFeatureFlagEvents: args.sendFeatureFlagEvents,
205
+ groups: args.groups as Record<string, string> | undefined,
206
+ personProperties: args.personProperties as Record<string, any> | undefined,
207
+ groupProperties: args.groupProperties as Record<string, Record<string, any>> | undefined,
157
208
  disableGeoip: args.disableGeoip,
209
+ flagKeys: args.flagKeys,
210
+ onlyEvaluateLocally: false,
158
211
  }
159
212
  }
160
213
 
161
- export const getFeatureFlag = action({
162
- args: featureFlagArgs,
163
- handler: async (_ctx, args): Promise<FeatureFlagValue | null> => {
164
- const client = createClient(args.apiKey, args.host)
165
- const result = await client.getFeatureFlag(args.key, args.distinctId, featureFlagOptions(args))
166
- await client.shutdown()
167
- return result ?? null
214
+ export const evaluateFlag = action({
215
+ args: { ...remoteFlagsArgs, key: v.string() },
216
+ handler: async (_ctx, args) => {
217
+ const client = getClient(args.apiKey, args.host)
218
+ // Scope the request to just the flag the caller asked about — otherwise PostHog evaluates
219
+ // every flag in the project on every call. Honour an explicit `flagKeys` override when given.
220
+ const snapshot = await client.evaluateFlags(args.distinctId, {
221
+ ...remoteFlagsOptions(args),
222
+ flagKeys: args.flagKeys ?? [args.key],
223
+ })
224
+ const value = snapshot.getFlag(args.key)
225
+ return value ?? null
168
226
  },
169
227
  })
170
228
 
171
- export const isFeatureEnabled = action({
172
- args: featureFlagArgs,
229
+ export const evaluateFlagPayload = action({
230
+ args: { ...remoteFlagsArgs, key: v.string() },
173
231
  handler: async (_ctx, args) => {
174
- const client = createClient(args.apiKey, args.host)
175
- const result = await client.isFeatureEnabled(args.key, args.distinctId, featureFlagOptions(args))
176
- await client.shutdown()
177
- return result ?? null
232
+ const client = getClient(args.apiKey, args.host)
233
+ const snapshot = await client.evaluateFlags(args.distinctId, {
234
+ ...remoteFlagsOptions(args),
235
+ flagKeys: args.flagKeys ?? [args.key],
236
+ })
237
+ const payload = snapshot.getFlagPayload(args.key)
238
+ return payload ?? null
178
239
  },
179
240
  })
180
241
 
181
- export const getFeatureFlagPayload = action({
182
- args: {
183
- ...featureFlagArgs,
184
- matchValue: v.optional(v.union(v.string(), v.boolean())),
242
+ export const evaluateAllFlags = action({
243
+ args: remoteFlagsArgs,
244
+ handler: async (_ctx, args) => {
245
+ const client = getClient(args.apiKey, args.host)
246
+ const snapshot = await client.evaluateFlags(args.distinctId, remoteFlagsOptions(args))
247
+ const featureFlags: Record<string, unknown> = {}
248
+ const featureFlagPayloads: Record<string, unknown> = {}
249
+ for (const key of snapshot.keys) {
250
+ const value = snapshot.getFlag(key)
251
+ if (value !== undefined) featureFlags[key] = value
252
+ const payload = snapshot.getFlagPayload(key)
253
+ if (payload !== undefined) featureFlagPayloads[key] = payload
254
+ }
255
+ return { featureFlags, featureFlagPayloads }
185
256
  },
186
- handler: async (_ctx, args): Promise<JsonType> => {
187
- const client = createClient(args.apiKey, args.host)
188
- const result = await client.getFeatureFlagPayload(
189
- args.key,
190
- args.distinctId,
191
- args.matchValue,
192
- featureFlagOptions(args)
193
- )
194
- await client.shutdown()
195
- return result ?? null
257
+ })
258
+
259
+ // --- Feature flag local evaluation ---
260
+ //
261
+ // Feature flag definitions are fetched on demand by `refreshFlagDefinitions` and stored in the
262
+ // `flagDefinitions` table. Clients read them via `getFlagDefinitions` and evaluate flags locally
263
+ // — there is no per-call action for flag evaluation.
264
+ //
265
+ // The action takes credentials as args. The consumer's app schedules the refresh cron and passes
266
+ // them in — typically via `posthog.refreshFlagDefinitions(ctx)` on the client class, which
267
+ // forwards the keys it was constructed with.
268
+
269
+ /**
270
+ * Returns the latest cached flag definitions, or `null` if none have been fetched yet.
271
+ *
272
+ * The `data` field is a JSON-stringified `FlagDefinitions` object (see `client/feature-flags/types.ts`).
273
+ */
274
+ export const getFlagDefinitions = query({
275
+ args: {},
276
+ handler: async (ctx) => {
277
+ const row = await ctx.db.query('flagDefinitions').order('desc').first()
278
+ if (!row) return null
279
+ return { data: row.data, fetchedAt: row.fetchedAt, etag: row.etag }
196
280
  },
197
281
  })
198
282
 
199
- export const getFeatureFlagResult = action({
200
- args: featureFlagArgs,
201
- handler: async (
202
- _ctx,
203
- args
204
- ): Promise<{
205
- key: string
206
- enabled: boolean
207
- variant: string | null
208
- payload: JsonType | null
209
- } | null> => {
210
- const client = createClient(args.apiKey, args.host)
211
- const result = await client.getFeatureFlagResult(args.key, args.distinctId, featureFlagOptions(args))
212
- await client.shutdown()
213
- if (!result) return null
214
- return {
215
- key: result.key,
216
- enabled: result.enabled,
217
- variant: result.variant ?? null,
218
- payload: result.payload ?? null,
283
+ // All three queries against `flagDefinitions` use `.order('desc').first()` so they all see the
284
+ // same row even if a stray duplicate ever lands in the table. Without consistent ordering,
285
+ // `_setFlagDefinitions` could upsert against an older row than the one `getFlagDefinitions`
286
+ // returns, leaving the row callers actually read perpetually stale.
287
+
288
+ export const _setFlagDefinitions = internalMutation({
289
+ args: { data: v.string(), etag: v.optional(v.string()) },
290
+ handler: async (ctx, args) => {
291
+ const existing = await ctx.db.query('flagDefinitions').order('desc').first()
292
+ const next = { data: args.data, fetchedAt: Date.now(), etag: args.etag }
293
+ if (existing) {
294
+ await ctx.db.replace(existing._id, next)
295
+ } else {
296
+ await ctx.db.insert('flagDefinitions', next)
219
297
  }
220
298
  },
221
299
  })
222
300
 
223
- const allFlagsArgs = {
224
- apiKey: v.string(),
225
- host: v.string(),
226
- distinctId: v.string(),
227
- groups: v.optional(v.any()),
228
- personProperties: v.optional(v.any()),
229
- groupProperties: v.optional(v.any()),
230
- disableGeoip: v.optional(v.boolean()),
231
- flagKeys: v.optional(v.array(v.string())),
232
- }
233
-
234
- export const getAllFlags = action({
235
- args: allFlagsArgs,
236
- handler: async (_ctx, args): Promise<Record<string, FeatureFlagValue>> => {
237
- const client = createClient(args.apiKey, args.host)
238
- const result = await client.getAllFlags(args.distinctId, {
239
- groups: args.groups,
240
- personProperties: args.personProperties,
241
- groupProperties: args.groupProperties,
242
- disableGeoip: args.disableGeoip,
243
- flagKeys: args.flagKeys,
244
- })
245
- await client.shutdown()
246
- return result
301
+ export const _getCurrentEtag = internalQuery({
302
+ args: {},
303
+ handler: async (ctx) => {
304
+ const row = await ctx.db.query('flagDefinitions').order('desc').first()
305
+ return row?.etag
247
306
  },
248
307
  })
249
308
 
250
- export const getAllFlagsAndPayloads = action({
251
- args: allFlagsArgs,
252
- handler: async (
253
- _ctx,
254
- args
255
- ): Promise<{
256
- featureFlags?: Record<string, FeatureFlagValue>
257
- featureFlagPayloads?: Record<string, JsonType>
258
- }> => {
259
- const client = createClient(args.apiKey, args.host)
260
- const result = await client.getAllFlagsAndPayloads(args.distinctId, {
261
- groups: args.groups,
262
- personProperties: args.personProperties,
263
- groupProperties: args.groupProperties,
264
- disableGeoip: args.disableGeoip,
265
- flagKeys: args.flagKeys,
309
+ /**
310
+ * Fetches flag definitions from PostHog's local-evaluation endpoint and stores them in the
311
+ * `flagDefinitions` table. Called by the consumer app's own cron — they pass in the keys via the
312
+ * `PostHog` client class which knows them already.
313
+ *
314
+ * Args:
315
+ * - `apiKey` — the project API key (`phc_…`)
316
+ * - `personalApiKey` — a feature flags secure API key (`phs_…`, recommended) or personal API
317
+ * key (`phx_…`) with feature-flag read access; local eval is disabled if missing
318
+ * - `host` — optional, defaults to `https://us.i.posthog.com`
319
+ */
320
+ export const refreshFlagDefinitions = action({
321
+ args: {
322
+ apiKey: v.string(),
323
+ personalApiKey: v.string(),
324
+ host: v.optional(v.string()),
325
+ },
326
+ handler: async (ctx, args) => {
327
+ const projectApiKey = args.apiKey.trim()
328
+ const personalApiKey = args.personalApiKey.trim()
329
+ const host = (args.host?.trim() || '').replace(/\/$/, '') || 'https://us.i.posthog.com'
330
+
331
+ if (!projectApiKey || !personalApiKey) {
332
+ // Local evaluation requires both keys. Return a status rather than throwing so the caller
333
+ // (typically a cron) can surface it cleanly.
334
+ return { status: 'skipped' as const, reason: 'missing-keys' as const }
335
+ }
336
+
337
+ const etag = await ctx.runQuery(internal.lib._getCurrentEtag, {})
338
+
339
+ const url = `${host}/flags/definitions?token=${projectApiKey}&send_cohorts`
340
+ const headers: Record<string, string> = {
341
+ 'Content-Type': 'application/json',
342
+ Authorization: `Bearer ${personalApiKey}`,
343
+ }
344
+ if (etag) headers['If-None-Match'] = etag
345
+
346
+ // PostHog's `/flags/definitions` endpoint sits behind a warm-on-demand cache. The first
347
+ // call after a flag is created — or any time the cache evicts — comes back as a 503 with
348
+ // "Required data not found in cache. … Please try again later." Retry transient 5xx (and
349
+ // 429s, since rate limiting on a one-minute cron is similarly worth waiting out) with
350
+ // bounded exponential backoff so a single cold-cache hit doesn't make callers wait a full
351
+ // cron tick. Tests override the delays via env var to keep retry-heavy cases snappy.
352
+ const testOverride = Number(process.env.POSTHOG_FLAGS_RETRY_DELAY_MS_OVERRIDE)
353
+ const RETRY_DELAYS_MS =
354
+ Number.isFinite(testOverride) && testOverride >= 0
355
+ ? [testOverride, testOverride, testOverride]
356
+ : [1500, 3000, 6000]
357
+ let response: Response
358
+ let attempt = 0
359
+ while (true) {
360
+ try {
361
+ response = await fetch(url, { method: 'GET', headers })
362
+ } catch (err) {
363
+ console.warn('[PostHog] Failed to fetch flag definitions:', err)
364
+ return { status: 'error' as const, reason: 'fetch-failed' as const }
365
+ }
366
+ const transient = response.status === 429 || (response.status >= 500 && response.status < 600)
367
+ if (!transient || attempt >= RETRY_DELAYS_MS.length) break
368
+ const wait = RETRY_DELAYS_MS[attempt]
369
+ attempt++
370
+ // Drain the body so the connection can be reused.
371
+ try {
372
+ await response.text()
373
+ } catch {
374
+ // ignore
375
+ }
376
+ console.warn(
377
+ `[PostHog] Flag definitions fetch returned ${response.status}; retrying in ${wait}ms (attempt ${attempt}/${RETRY_DELAYS_MS.length}).`
378
+ )
379
+ await new Promise((r) => setTimeout(r, wait))
380
+ }
381
+
382
+ if (response.status === 304) {
383
+ return { status: 'unchanged' as const }
384
+ }
385
+ if (response.status === 401 || response.status === 403) {
386
+ console.warn(
387
+ `[PostHog] Flag definitions fetch failed with ${response.status}. ` +
388
+ `Check that the personal/feature-flags-secure API key has read access to feature flags.`
389
+ )
390
+ return { status: 'error' as const, reason: 'auth' as const }
391
+ }
392
+ if (response.status === 402) {
393
+ console.warn('[PostHog] Feature flags quota limit exceeded — disabling local evaluation.')
394
+ await ctx.runMutation(internal.lib._setFlagDefinitions, {
395
+ data: JSON.stringify({ flags: [], groupTypeMapping: {}, cohorts: {} }),
396
+ etag: undefined,
397
+ })
398
+ return { status: 'error' as const, reason: 'quota' as const }
399
+ }
400
+ if (response.status === 429) {
401
+ console.warn('[PostHog] Rate limited while fetching flag definitions (after retries).')
402
+ return { status: 'error' as const, reason: 'rate-limited' as const }
403
+ }
404
+ if (response.status !== 200) {
405
+ let bodyText = '<no body>'
406
+ try {
407
+ bodyText = (await response.text()).slice(0, 500)
408
+ } catch {
409
+ // ignore — body wasn't readable
410
+ }
411
+ // PostHog returns 503 with `Required data not found in cache` for two indistinguishable
412
+ // cases: (a) the project has zero flag definitions configured, and (b) the warm-on-demand
413
+ // cache evicted and hasn't repopulated yet. We can't tell which, so we treat them the same
414
+ // way: if we have no existing defs cached, persist an empty snapshot so eval methods can
415
+ // resolve flag lookups to `undefined` cleanly and the UI stops looking broken. If we
416
+ // already had defs cached, leave them alone — last-known-good beats a flap.
417
+ const looksCacheCold =
418
+ response.status === 503 && bodyText.toLowerCase().includes('required data not found in cache')
419
+ if (looksCacheCold) {
420
+ const existing = await ctx.runQuery(api.lib.getFlagDefinitions, {})
421
+ const STALE_AFTER_MS = 5 * 60 * 1000
422
+ if (existing === null) {
423
+ // No prior cache — write an empty snapshot so subsequent reads are deterministic and
424
+ // the UI shows "no flags" instead of "loading".
425
+ await ctx.runMutation(internal.lib._setFlagDefinitions, {
426
+ data: JSON.stringify({ flags: [], groupTypeMapping: {}, cohorts: {} }),
427
+ etag: undefined,
428
+ })
429
+ console.info(
430
+ "[PostHog] No flag definitions returned (project may have no flags yet, or PostHog's cache is warming). Cached an empty snapshot."
431
+ )
432
+ return { status: 'empty' as const }
433
+ }
434
+ if (Date.now() - existing.fetchedAt > STALE_AFTER_MS) {
435
+ // We had cached defs but haven't successfully refreshed them in a while — could be that
436
+ // every flag was deleted upstream and PostHog now responds with "no flags in cache" 503s.
437
+ // Replace with an empty snapshot rather than serving stale data indefinitely.
438
+ await ctx.runMutation(internal.lib._setFlagDefinitions, {
439
+ data: JSON.stringify({ flags: [], groupTypeMapping: {}, cohorts: {} }),
440
+ etag: undefined,
441
+ })
442
+ console.info(
443
+ '[PostHog] Cached flag definitions are >5 minutes stale and PostHog reports an empty cache. Replaced with an empty snapshot.'
444
+ )
445
+ return { status: 'empty' as const }
446
+ }
447
+ // Recent cached defs — keep them while PostHog's cache potentially warms back up.
448
+ return { status: 'stale' as const }
449
+ }
450
+
451
+ console.warn(
452
+ `[PostHog] Unexpected status ${response.status} fetching flag definitions from ${url.replace(projectApiKey, '<token>')}. ` +
453
+ `Response body: ${bodyText}`
454
+ )
455
+ return { status: 'error' as const, reason: 'unexpected-status' as const }
456
+ }
457
+
458
+ let body: { flags?: unknown; group_type_mapping?: unknown; cohorts?: unknown }
459
+ try {
460
+ body = (await response.json()) as typeof body
461
+ } catch (err) {
462
+ console.warn('[PostHog] Failed to parse flag definitions response:', err)
463
+ return { status: 'error' as const, reason: 'parse-failed' as const }
464
+ }
465
+ if (!('flags' in body)) {
466
+ console.warn('[PostHog] Flag definitions response missing `flags` field.')
467
+ return { status: 'error' as const, reason: 'invalid-shape' as const }
468
+ }
469
+
470
+ const data = JSON.stringify({
471
+ flags: body.flags ?? [],
472
+ groupTypeMapping: body.group_type_mapping ?? {},
473
+ cohorts: body.cohorts ?? {},
266
474
  })
267
- await client.shutdown()
268
- return result
475
+
476
+ await ctx.runMutation(internal.lib._setFlagDefinitions, {
477
+ data,
478
+ etag: response.headers.get('ETag') ?? undefined,
479
+ })
480
+
481
+ return { status: 'updated' as const }
269
482
  },
270
483
  })
@@ -1,3 +1,17 @@
1
- import { defineSchema } from 'convex/server'
1
+ import { defineSchema, defineTable } from 'convex/server'
2
+ import { v } from 'convex/values'
2
3
 
3
- export default defineSchema({})
4
+ export default defineSchema({
5
+ /**
6
+ * Singleton table holding the latest feature flag definitions fetched from the PostHog API.
7
+ * The cron action upserts a single row; clients read it for local evaluation.
8
+ *
9
+ * `data` is a JSON-stringified `FlagDefinitions` object to bypass Convex's restriction on
10
+ * field names beginning with `$` (flag conditions reference properties like `$device_id`).
11
+ */
12
+ flagDefinitions: defineTable({
13
+ data: v.string(),
14
+ fetchedAt: v.number(),
15
+ etag: v.optional(v.string()),
16
+ }),
17
+ })
@@ -0,0 +1 @@
1
+ export const version = '1.0.1'