@posthog/convex 1.0.10 → 2.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 (41) hide show
  1. package/README.md +88 -36
  2. package/dist/client/index.d.ts +17 -22
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +26 -41
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/api.d.ts +4 -0
  7. package/dist/component/_generated/api.d.ts.map +1 -1
  8. package/dist/component/_generated/api.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +1 -21
  10. package/dist/component/_generated/component.d.ts.map +1 -1
  11. package/dist/component/_generated/server.d.ts +11 -0
  12. package/dist/component/_generated/server.d.ts.map +1 -1
  13. package/dist/component/_generated/server.js +1 -0
  14. package/dist/component/_generated/server.js.map +1 -1
  15. package/dist/component/convex.config.d.ts +18 -1
  16. package/dist/component/convex.config.d.ts.map +1 -1
  17. package/dist/component/convex.config.js +21 -1
  18. package/dist/component/convex.config.js.map +1 -1
  19. package/dist/component/crons.d.ts +5 -0
  20. package/dist/component/crons.d.ts.map +1 -0
  21. package/dist/component/crons.js +26 -0
  22. package/dist/component/crons.js.map +1 -0
  23. package/dist/component/lib.d.ts +11 -35
  24. package/dist/component/lib.d.ts.map +1 -1
  25. package/dist/component/lib.js +84 -63
  26. package/dist/component/lib.js.map +1 -1
  27. package/dist/component/version.d.ts +1 -1
  28. package/dist/component/version.d.ts.map +1 -1
  29. package/dist/component/version.js +1 -1
  30. package/dist/component/version.js.map +1 -1
  31. package/package.json +7 -6
  32. package/src/client/index.test.ts +85 -63
  33. package/src/client/index.ts +35 -60
  34. package/src/component/_generated/api.ts +4 -0
  35. package/src/component/_generated/component.ts +3 -27
  36. package/src/component/_generated/server.ts +11 -0
  37. package/src/component/convex.config.ts +21 -1
  38. package/src/component/crons.test.ts +96 -0
  39. package/src/component/crons.ts +36 -0
  40. package/src/component/lib.ts +80 -62
  41. package/src/component/version.ts +1 -1
@@ -27,13 +27,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
27
27
  alias: FunctionReference<
28
28
  "action",
29
29
  "internal",
30
- {
31
- alias: string;
32
- apiKey: string;
33
- disableGeoip?: boolean;
34
- distinctId: string;
35
- host: string;
36
- },
30
+ { alias: string; disableGeoip?: boolean; distinctId: string },
37
31
  any,
38
32
  Name
39
33
  >;
@@ -41,12 +35,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
41
35
  "action",
42
36
  "internal",
43
37
  {
44
- apiKey: string;
45
38
  disableGeoip?: boolean;
46
39
  distinctId: string;
47
40
  event: string;
48
41
  groups?: string;
49
- host: string;
50
42
  properties?: string;
51
43
  sendFeatureFlags?: boolean;
52
44
  timestamp?: number;
@@ -60,12 +52,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
60
52
  "internal",
61
53
  {
62
54
  additionalProperties?: string;
63
- apiKey: string;
64
55
  distinctId?: string;
65
56
  errorMessage: string;
66
57
  errorName?: string;
67
58
  errorStack?: string;
68
- host: string;
69
59
  },
70
60
  any,
71
61
  Name
@@ -74,13 +64,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
74
64
  "action",
75
65
  "internal",
76
66
  {
77
- apiKey: string;
78
67
  disableGeoip?: boolean;
79
68
  distinctId: string;
80
69
  flagKeys?: Array<string>;
81
70
  groupProperties?: any;
82
71
  groups?: any;
83
- host: string;
84
72
  personProperties?: any;
85
73
  },
86
74
  any,
@@ -90,13 +78,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
90
78
  "action",
91
79
  "internal",
92
80
  {
93
- apiKey: string;
94
81
  disableGeoip?: boolean;
95
82
  distinctId: string;
96
83
  flagKeys?: Array<string>;
97
84
  groupProperties?: any;
98
85
  groups?: any;
99
- host: string;
100
86
  key: string;
101
87
  personProperties?: any;
102
88
  },
@@ -107,13 +93,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
107
93
  "action",
108
94
  "internal",
109
95
  {
110
- apiKey: string;
111
96
  disableGeoip?: boolean;
112
97
  distinctId: string;
113
98
  flagKeys?: Array<string>;
114
99
  groupProperties?: any;
115
100
  groups?: any;
116
- host: string;
117
101
  key: string;
118
102
  personProperties?: any;
119
103
  },
@@ -125,12 +109,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
125
109
  "action",
126
110
  "internal",
127
111
  {
128
- apiKey: string;
129
112
  disableGeoip?: boolean;
130
113
  distinctId?: string;
131
114
  groupKey: string;
132
115
  groupType: string;
133
- host: string;
134
116
  properties?: string;
135
117
  },
136
118
  any,
@@ -139,20 +121,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
139
121
  identify: FunctionReference<
140
122
  "action",
141
123
  "internal",
142
- {
143
- apiKey: string;
144
- disableGeoip?: boolean;
145
- distinctId: string;
146
- host: string;
147
- properties?: string;
148
- },
124
+ { disableGeoip?: boolean; distinctId: string; properties?: string },
149
125
  any,
150
126
  Name
151
127
  >;
152
128
  refreshFlagDefinitions: FunctionReference<
153
129
  "action",
154
130
  "internal",
155
- { apiKey: string; host?: string; personalApiKey: string },
131
+ {},
156
132
  any,
157
133
  Name
158
134
  >;
@@ -30,6 +30,16 @@ import {
30
30
  } from "convex/server";
31
31
  import type { DataModel } from "./dataModel.js";
32
32
 
33
+ /**
34
+ * Typesafe environment variables declared in `convex.config.ts`.
35
+ */
36
+ type Env = {
37
+ readonly POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS: string | undefined;
38
+ readonly POSTHOG_HOST: string | undefined;
39
+ readonly POSTHOG_PERSONAL_API_KEY: string | undefined;
40
+ readonly POSTHOG_PROJECT_TOKEN: string;
41
+ };
42
+
33
43
  /**
34
44
  * Define a query in this Convex app's public API.
35
45
  *
@@ -106,6 +116,7 @@ export const internalAction: ActionBuilder<DataModel, "internal"> =
106
116
  * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
107
117
  */
108
118
  export const httpAction: HttpActionBuilder = httpActionGeneric;
119
+ export const env: Env = process.env as unknown as Env;
109
120
 
110
121
  /**
111
122
  * A set of services for use within Convex query functions.
@@ -1,3 +1,23 @@
1
1
  import { defineComponent } from 'convex/server'
2
+ import { v } from 'convex/values'
2
3
 
3
- export default defineComponent('posthog')
4
+ /**
5
+ * The component declares the env vars it needs so the installing app can wire them in
6
+ * `convex/convex.config.ts` (typically via `app.env.*` so existing project-level env vars
7
+ * pass straight through). All three are read via `process.env` inside the component's
8
+ * actions and cron — `POSTHOG_PERSONAL_API_KEY`'s presence is also what gates the local
9
+ * evaluation refresh cron.
10
+ */
11
+ export default defineComponent('posthog', {
12
+ env: {
13
+ POSTHOG_PROJECT_TOKEN: v.string(),
14
+ POSTHOG_HOST: v.optional(v.string()),
15
+ POSTHOG_PERSONAL_API_KEY: v.optional(v.string()),
16
+ /**
17
+ * Polling interval for the local-evaluation refresh cron, in whole seconds. Optional
18
+ * (defaults to 60). Convex component env vars are string-typed on the wire, so this is
19
+ * parsed at module load — invalid values log a warning and fall back to the default.
20
+ */
21
+ POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS: v.optional(v.string()),
22
+ },
23
+ })
@@ -0,0 +1,96 @@
1
+ import { describe, expect, test, beforeEach, afterEach, jest } from '@jest/globals'
2
+ import type { Crons } from 'convex/server'
3
+ import { DEFAULT_INTERVAL_SECONDS, readPollingIntervalSeconds } from './crons.js'
4
+
5
+ describe('cron registration', () => {
6
+ let originalPak: string | undefined
7
+
8
+ beforeEach(() => {
9
+ originalPak = process.env.POSTHOG_PERSONAL_API_KEY
10
+ jest.resetModules()
11
+ })
12
+
13
+ afterEach(() => {
14
+ if (originalPak === undefined) {
15
+ delete process.env.POSTHOG_PERSONAL_API_KEY
16
+ } else {
17
+ process.env.POSTHOG_PERSONAL_API_KEY = originalPak
18
+ }
19
+ })
20
+
21
+ // Convex forwards component env vars only at runtime, so deploy-time module analysis sees
22
+ // `process.env.POSTHOG_PERSONAL_API_KEY` empty even when the installing app has set it.
23
+ // A load-time gate would silently drop the cron — see #3683.
24
+ test.each<[string, string | undefined]>([
25
+ ['unset', undefined],
26
+ ['set', 'phx_test'],
27
+ ])('registers the refresh cron when POSTHOG_PERSONAL_API_KEY is %s at module load', async (_label, pakValue) => {
28
+ if (pakValue === undefined) {
29
+ delete process.env.POSTHOG_PERSONAL_API_KEY
30
+ } else {
31
+ process.env.POSTHOG_PERSONAL_API_KEY = pakValue
32
+ }
33
+ const mod = (await import('./crons.js')) as { default: Crons }
34
+ expect(Object.keys(mod.default.crons)).toContain('Refresh PostHog feature flag definitions')
35
+ })
36
+ })
37
+
38
+ describe('readPollingIntervalSeconds', () => {
39
+ let warnSpy: ReturnType<typeof jest.spyOn>
40
+
41
+ beforeEach(() => {
42
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
43
+ })
44
+
45
+ afterEach(() => {
46
+ warnSpy.mockRestore()
47
+ delete process.env.POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS
48
+ })
49
+
50
+ test('returns the default when the env var is unset', () => {
51
+ expect(readPollingIntervalSeconds()).toBe(DEFAULT_INTERVAL_SECONDS)
52
+ expect(warnSpy).not.toHaveBeenCalled()
53
+ })
54
+
55
+ test('returns the default when the env var is empty or whitespace', () => {
56
+ process.env.POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS = ' '
57
+ expect(readPollingIntervalSeconds()).toBe(DEFAULT_INTERVAL_SECONDS)
58
+ expect(warnSpy).not.toHaveBeenCalled()
59
+ })
60
+
61
+ test('parses a positive integer', () => {
62
+ process.env.POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS = '300'
63
+ expect(readPollingIntervalSeconds()).toBe(300)
64
+ expect(warnSpy).not.toHaveBeenCalled()
65
+ })
66
+
67
+ test('trims whitespace before parsing', () => {
68
+ process.env.POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS = ' 120 '
69
+ expect(readPollingIntervalSeconds()).toBe(120)
70
+ expect(warnSpy).not.toHaveBeenCalled()
71
+ })
72
+
73
+ test('warns and falls back for non-numeric input', () => {
74
+ process.env.POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS = 'abc'
75
+ expect(readPollingIntervalSeconds()).toBe(DEFAULT_INTERVAL_SECONDS)
76
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"abc"'))
77
+ })
78
+
79
+ test('warns and falls back for zero', () => {
80
+ process.env.POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS = '0'
81
+ expect(readPollingIntervalSeconds()).toBe(DEFAULT_INTERVAL_SECONDS)
82
+ expect(warnSpy).toHaveBeenCalled()
83
+ })
84
+
85
+ test('warns and falls back for negative values', () => {
86
+ process.env.POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS = '-30'
87
+ expect(readPollingIntervalSeconds()).toBe(DEFAULT_INTERVAL_SECONDS)
88
+ expect(warnSpy).toHaveBeenCalled()
89
+ })
90
+
91
+ test('warns and falls back for fractional values', () => {
92
+ process.env.POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS = '60.5'
93
+ expect(readPollingIntervalSeconds()).toBe(DEFAULT_INTERVAL_SECONDS)
94
+ expect(warnSpy).toHaveBeenCalled()
95
+ })
96
+ })
@@ -0,0 +1,36 @@
1
+ import { cronJobs } from 'convex/server'
2
+ import { api } from './_generated/api.js'
3
+ import { env } from './_generated/server.js'
4
+
5
+ const crons = cronJobs()
6
+
7
+ // Override via `POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS`.
8
+ export const DEFAULT_INTERVAL_SECONDS = 60
9
+
10
+ // Convex component env vars are string-typed. Invalid values warn and fall back rather than
11
+ // failing the deploy. Exported for unit testing.
12
+ export function readPollingIntervalSeconds(): number {
13
+ const raw = (env.POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS ?? '').trim()
14
+ if (!raw) return DEFAULT_INTERVAL_SECONDS
15
+ const parsed = Number(raw)
16
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) {
17
+ console.warn(
18
+ `[PostHog] POSTHOG_FLAGS_POLLING_INTERVAL_SECONDS="${raw}" is not a positive integer; ` +
19
+ `falling back to ${DEFAULT_INTERVAL_SECONDS}s.`
20
+ )
21
+ return DEFAULT_INTERVAL_SECONDS
22
+ }
23
+ return parsed
24
+ }
25
+
26
+ // Registered unconditionally — Convex forwards component env vars only at runtime, so a
27
+ // load-time gate on `POSTHOG_PERSONAL_API_KEY` sees an empty value at deploy-time module
28
+ // analysis and silently drops the cron. The handler in `lib.ts` gates at runtime instead.
29
+ crons.interval(
30
+ 'Refresh PostHog feature flag definitions',
31
+ { seconds: readPollingIntervalSeconds() },
32
+ api.lib.refreshFlagDefinitions,
33
+ {}
34
+ )
35
+
36
+ export default crons
@@ -1,5 +1,5 @@
1
1
  import { PostHog as PostHogEdge } from 'posthog-node/edge'
2
- import { action, internalMutation, internalQuery, query } from './_generated/server.js'
2
+ import { action, env, internalMutation, internalQuery, query } from './_generated/server.js'
3
3
  import { api, internal } from './_generated/api.js'
4
4
  import { v } from 'convex/values'
5
5
  import { version } from './version.js'
@@ -18,6 +18,33 @@ class PostHog extends PostHogEdge {
18
18
  }
19
19
  }
20
20
 
21
+ const DEFAULT_HOST = 'https://us.i.posthog.com'
22
+
23
+ /**
24
+ * Resolve the credentials and host the component was configured with.
25
+ *
26
+ * Reads the typed `env` from `_generated/server` (declared in `convex.config.ts`). The
27
+ * installing app wires the values via `app.use(posthog, { env: { ... } })`, typically
28
+ * threading them straight through from its own deployment env vars. Trimming guards
29
+ * against accidental whitespace from `npx convex env set`.
30
+ */
31
+ function readConfig(): { projectToken: string; host: string; personalApiKey: string } {
32
+ const projectToken = (env.POSTHOG_PROJECT_TOKEN ?? '').trim()
33
+ const host = (env.POSTHOG_HOST ?? '').trim() || DEFAULT_HOST
34
+ const personalApiKey = (env.POSTHOG_PERSONAL_API_KEY ?? '').trim()
35
+ if (!projectToken) {
36
+ // Convex's typed env-var validation should prevent an empty `POSTHOG_PROJECT_TOKEN` at deploy time,
37
+ // but the gate is enforced at the app's `convex.config.ts`. Log loudly here so anyone hitting
38
+ // an unexpected empty value (e.g. the token was cleared post-deploy on a stale isolate) has a trail
39
+ // to follow rather than silently dropped events.
40
+ console.warn(
41
+ '[PostHog] POSTHOG_PROJECT_TOKEN is not configured; this event will be dropped. ' +
42
+ 'Set it with `npx convex env set POSTHOG_PROJECT_TOKEN phc_…` and redeploy.'
43
+ )
44
+ }
45
+ return { projectToken, host, personalApiKey }
46
+ }
47
+
21
48
  /**
22
49
  * Cache PostHog clients across action invocations within the same Convex isolate.
23
50
  *
@@ -25,15 +52,16 @@ class PostHog extends PostHogEdge {
25
52
  * a fresh client per call (and tearing it down with `shutdown()`) is wasted work — the client
26
53
  * carries no per-invocation state once `flush()` has drained its queue.
27
54
  *
28
- * Keyed by `apiKey|host` to support the rare case of multiple credentials sharing one isolate.
55
+ * Keyed by `projectToken|host` so a deployment that rotates its env vars (via `npx convex env set`)
56
+ * picks up the new client without restarting the isolate.
29
57
  */
30
58
  const clientCache = new Map<string, PostHog>()
31
59
 
32
- function getClient(apiKey: string, host: string): PostHog {
33
- const key = `${apiKey}|${host}`
60
+ function getClient(projectToken: string, host: string): PostHog {
61
+ const key = `${projectToken}|${host}`
34
62
  let client = clientCache.get(key)
35
63
  if (!client) {
36
- client = new PostHog(apiKey, { host, flushAt: 1, flushInterval: 0 })
64
+ client = new PostHog(projectToken, { host, flushAt: 1, flushInterval: 0 })
37
65
  clientCache.set(key, client)
38
66
  }
39
67
  return client
@@ -52,8 +80,6 @@ function parseProperties(json: string | undefined): Record<string, unknown> | un
52
80
 
53
81
  export const capture = action({
54
82
  args: {
55
- apiKey: v.string(),
56
- host: v.string(),
57
83
  distinctId: v.string(),
58
84
  event: v.string(),
59
85
  properties: v.optional(v.string()),
@@ -64,7 +90,9 @@ export const capture = action({
64
90
  disableGeoip: v.optional(v.boolean()),
65
91
  },
66
92
  handler: async (_ctx, args) => {
67
- const client = getClient(args.apiKey, args.host)
93
+ const { projectToken, host } = readConfig()
94
+ if (!projectToken) return
95
+ const client = getClient(projectToken, host)
68
96
  await client.captureImmediate({
69
97
  distinctId: args.distinctId,
70
98
  event: args.event,
@@ -80,14 +108,14 @@ export const capture = action({
80
108
 
81
109
  export const identify = action({
82
110
  args: {
83
- apiKey: v.string(),
84
- host: v.string(),
85
111
  distinctId: v.string(),
86
112
  properties: v.optional(v.string()),
87
113
  disableGeoip: v.optional(v.boolean()),
88
114
  },
89
115
  handler: async (_ctx, args) => {
90
- const client = getClient(args.apiKey, args.host)
116
+ const { projectToken, host } = readConfig()
117
+ if (!projectToken) return
118
+ const client = getClient(projectToken, host)
91
119
  // posthog-node's `identifyImmediate` is missing an `await` on `identifyStatelessImmediate`
92
120
  // (packages/node/src/client.ts:674), so the returned promise resolves before the event hits
93
121
  // the wire. We sidestep that by composing the `$identify` event the same way `identifyImmediate`
@@ -113,8 +141,6 @@ export const identify = action({
113
141
 
114
142
  export const groupIdentify = action({
115
143
  args: {
116
- apiKey: v.string(),
117
- host: v.string(),
118
144
  groupType: v.string(),
119
145
  groupKey: v.string(),
120
146
  properties: v.optional(v.string()),
@@ -122,7 +148,9 @@ export const groupIdentify = action({
122
148
  disableGeoip: v.optional(v.boolean()),
123
149
  },
124
150
  handler: async (_ctx, args) => {
125
- const client = getClient(args.apiKey, args.host)
151
+ const { projectToken, host } = readConfig()
152
+ if (!projectToken) return
153
+ const client = getClient(projectToken, host)
126
154
  // posthog-node doesn't expose a `groupIdentifyImmediate`, so we send the same `$groupidentify`
127
155
  // event via `captureImmediate` to keep parity with capture/identify/alias/captureException —
128
156
  // resolve when the network call completes, without resorting to shutdown().
@@ -141,14 +169,14 @@ export const groupIdentify = action({
141
169
 
142
170
  export const alias = action({
143
171
  args: {
144
- apiKey: v.string(),
145
- host: v.string(),
146
172
  distinctId: v.string(),
147
173
  alias: v.string(),
148
174
  disableGeoip: v.optional(v.boolean()),
149
175
  },
150
176
  handler: async (_ctx, args) => {
151
- const client = getClient(args.apiKey, args.host)
177
+ const { projectToken, host } = readConfig()
178
+ if (!projectToken) return
179
+ const client = getClient(projectToken, host)
152
180
  await client.aliasImmediate({
153
181
  distinctId: args.distinctId,
154
182
  alias: args.alias,
@@ -159,8 +187,6 @@ export const alias = action({
159
187
 
160
188
  export const captureException = action({
161
189
  args: {
162
- apiKey: v.string(),
163
- host: v.string(),
164
190
  distinctId: v.optional(v.string()),
165
191
  errorMessage: v.string(),
166
192
  errorStack: v.optional(v.string()),
@@ -168,7 +194,9 @@ export const captureException = action({
168
194
  additionalProperties: v.optional(v.string()),
169
195
  },
170
196
  handler: async (_ctx, args) => {
171
- const client = getClient(args.apiKey, args.host)
197
+ const { projectToken, host } = readConfig()
198
+ if (!projectToken) return
199
+ const client = getClient(projectToken, host)
172
200
  const error = new Error(args.errorMessage)
173
201
  if (args.errorName) error.name = args.errorName
174
202
  if (args.errorStack) error.stack = args.errorStack
@@ -184,8 +212,6 @@ export const captureException = action({
184
212
  // require an action context — that's the trade for not needing flag definitions cached upfront.
185
213
 
186
214
  const remoteFlagsArgs = {
187
- apiKey: v.string(),
188
- host: v.string(),
189
215
  distinctId: v.string(),
190
216
  groups: v.optional(v.any()),
191
217
  personProperties: v.optional(v.any()),
@@ -214,7 +240,9 @@ function remoteFlagsOptions(args: {
214
240
  export const evaluateFlag = action({
215
241
  args: { ...remoteFlagsArgs, key: v.string() },
216
242
  handler: async (_ctx, args) => {
217
- const client = getClient(args.apiKey, args.host)
243
+ const { projectToken, host } = readConfig()
244
+ if (!projectToken) return null
245
+ const client = getClient(projectToken, host)
218
246
  // Scope the request to just the flag the caller asked about — otherwise PostHog evaluates
219
247
  // every flag in the project on every call. Honour an explicit `flagKeys` override when given.
220
248
  const snapshot = await client.evaluateFlags(args.distinctId, {
@@ -229,7 +257,9 @@ export const evaluateFlag = action({
229
257
  export const evaluateFlagPayload = action({
230
258
  args: { ...remoteFlagsArgs, key: v.string() },
231
259
  handler: async (_ctx, args) => {
232
- const client = getClient(args.apiKey, args.host)
260
+ const { projectToken, host } = readConfig()
261
+ if (!projectToken) return null
262
+ const client = getClient(projectToken, host)
233
263
  const snapshot = await client.evaluateFlags(args.distinctId, {
234
264
  ...remoteFlagsOptions(args),
235
265
  flagKeys: args.flagKeys ?? [args.key],
@@ -242,7 +272,9 @@ export const evaluateFlagPayload = action({
242
272
  export const evaluateAllFlags = action({
243
273
  args: remoteFlagsArgs,
244
274
  handler: async (_ctx, args) => {
245
- const client = getClient(args.apiKey, args.host)
275
+ const { projectToken, host } = readConfig()
276
+ if (!projectToken) return { featureFlags: {}, featureFlagPayloads: {} }
277
+ const client = getClient(projectToken, host)
246
278
  const snapshot = await client.evaluateFlags(args.distinctId, remoteFlagsOptions(args))
247
279
  const featureFlags: Record<string, unknown> = {}
248
280
  const featureFlagPayloads: Record<string, unknown> = {}
@@ -258,25 +290,23 @@ export const evaluateAllFlags = action({
258
290
 
259
291
  // --- Feature flag local evaluation ---
260
292
  //
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.
293
+ // Flag definitions are fetched on the cron in `crons.ts` and stored in `flagDefinitions`.
294
+ // Clients read them via `getFlagDefinitions` and evaluate flags locally — there is no
295
+ // per-call action for flag evaluation.
268
296
 
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
- */
297
+ // `localEvalConfigured` lets the client distinguish "PAK not set" (throw, point at the
298
+ // remote `evaluateFlag` methods) from "PAK set but cron hasn't fetched yet" (return
299
+ // `undefined`). `data` is a JSON-stringified `FlagDefinitions` (see
300
+ // `client/feature-flags/types.ts`), null until the first successful refresh.
274
301
  export const getFlagDefinitions = query({
275
302
  args: {},
276
303
  handler: async (ctx) => {
304
+ const localEvalConfigured = !!(env.POSTHOG_PERSONAL_API_KEY ?? '').trim()
277
305
  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 }
306
+ if (!row) {
307
+ return { localEvalConfigured, data: null, fetchedAt: null, etag: undefined }
308
+ }
309
+ return { localEvalConfigured, data: row.data, fetchedAt: row.fetchedAt, etag: row.etag }
280
310
  },
281
311
  })
282
312
 
@@ -308,35 +338,23 @@ export const _getCurrentEtag = internalQuery({
308
338
 
309
339
  /**
310
340
  * 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`
341
+ * `flagDefinitions` table. Called automatically by the cron registered in `crons.ts` when
342
+ * `POSTHOG_PERSONAL_API_KEY` is set, and also exposed publicly so the client's
343
+ * `reloadFeatureFlags(ctx)` method (parity with `posthog-node`) can trigger an on-demand refresh.
319
344
  */
320
345
  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'
346
+ args: {},
347
+ handler: async (ctx) => {
348
+ const { projectToken, host, personalApiKey } = readConfig()
330
349
 
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.
350
+ if (!projectToken || !personalApiKey) {
351
+ // The cron registers unconditionally (see `crons.ts`); this is its runtime gate.
334
352
  return { status: 'skipped' as const, reason: 'missing-keys' as const }
335
353
  }
336
354
 
337
355
  const etag = await ctx.runQuery(internal.lib._getCurrentEtag, {})
338
356
 
339
- const url = `${host}/flags/definitions?token=${projectApiKey}&send_cohorts`
357
+ const url = `${host.replace(/\/$/, '')}/flags/definitions?token=${projectToken}&send_cohorts`
340
358
  const headers: Record<string, string> = {
341
359
  'Content-Type': 'application/json',
342
360
  Authorization: `Bearer ${personalApiKey}`,
@@ -419,7 +437,7 @@ export const refreshFlagDefinitions = action({
419
437
  if (looksCacheCold) {
420
438
  const existing = await ctx.runQuery(api.lib.getFlagDefinitions, {})
421
439
  const STALE_AFTER_MS = 5 * 60 * 1000
422
- if (existing === null) {
440
+ if (existing.fetchedAt === null) {
423
441
  // No prior cache — write an empty snapshot so subsequent reads are deterministic and
424
442
  // the UI shows "no flags" instead of "loading".
425
443
  await ctx.runMutation(internal.lib._setFlagDefinitions, {
@@ -449,7 +467,7 @@ export const refreshFlagDefinitions = action({
449
467
  }
450
468
 
451
469
  console.warn(
452
- `[PostHog] Unexpected status ${response.status} fetching flag definitions from ${url.replace(projectApiKey, '<token>')}. ` +
470
+ `[PostHog] Unexpected status ${response.status} fetching flag definitions from ${url.replace(projectToken, '<token>')}. ` +
453
471
  `Response body: ${bodyText}`
454
472
  )
455
473
  return { status: 'error' as const, reason: 'unexpected-status' as const }
@@ -1 +1 @@
1
- export const version = '1.0.10'
1
+ export const version = '2.0.1'