@posthog/convex 0.2.32 → 1.0.0

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