@wireai/activation 0.11.0 → 0.12.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 (73) hide show
  1. package/AGENTS.md +62 -8
  2. package/CHANGELOG.md +188 -3
  3. package/INTEGRATION_PROMPT.md +25 -2
  4. package/README.md +112 -1
  5. package/dist/analytics/index.d.mts +18 -6
  6. package/dist/analytics/index.d.ts +18 -6
  7. package/dist/analytics/index.js +151 -41
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +147 -42
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/coachmarks/index.d.mts +16 -0
  12. package/dist/coachmarks/index.d.ts +16 -0
  13. package/dist/coachmarks/index.js +19 -13
  14. package/dist/coachmarks/index.js.map +1 -1
  15. package/dist/coachmarks/index.mjs +19 -13
  16. package/dist/coachmarks/index.mjs.map +1 -1
  17. package/dist/{currentSession-C0_odnIW.d.mts → currentSession-BlCeDP0f.d.mts} +145 -33
  18. package/dist/{currentSession-DdnUq2HQ.d.ts → currentSession-BxEB37xt.d.ts} +145 -33
  19. package/dist/index.d.mts +243 -53
  20. package/dist/index.d.ts +243 -53
  21. package/dist/index.js +531 -156
  22. package/dist/index.js.map +1 -1
  23. package/dist/index.mjs +517 -157
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/questionnaire/index.d.mts +1 -1
  26. package/dist/questionnaire/index.d.ts +1 -1
  27. package/dist/questionnaire/index.js +79 -14
  28. package/dist/questionnaire/index.js.map +1 -1
  29. package/dist/questionnaire/index.mjs +79 -14
  30. package/dist/questionnaire/index.mjs.map +1 -1
  31. package/dist/reviews/index.d.mts +2 -2
  32. package/dist/reviews/index.d.ts +2 -2
  33. package/dist/reviews/index.js +103 -16
  34. package/dist/reviews/index.js.map +1 -1
  35. package/dist/reviews/index.mjs +103 -16
  36. package/dist/reviews/index.mjs.map +1 -1
  37. package/dist/showcase/index.js +15 -6
  38. package/dist/showcase/index.js.map +1 -1
  39. package/dist/showcase/index.mjs +15 -6
  40. package/dist/showcase/index.mjs.map +1 -1
  41. package/dist/{transport-BGW9uXZJ.d.mts → transport-CF_eHwzC.d.mts} +15 -1
  42. package/dist/{transport-jUJd5kxu.d.ts → transport-DsRe4epC.d.ts} +15 -1
  43. package/llms.txt +3 -0
  44. package/package.json +1 -1
  45. package/src/WireOnboarding.tsx +140 -5
  46. package/src/activation/useWireActivation.ts +12 -1
  47. package/src/activation/wireActivation.ts +44 -25
  48. package/src/analytics/analyticsFacade.ts +54 -29
  49. package/src/analytics/currentSession.ts +83 -0
  50. package/src/analytics/eventQueue.ts +9 -1
  51. package/src/analytics/index.ts +20 -1
  52. package/src/analytics/reportClientEvent.ts +42 -0
  53. package/src/analytics/screenTracking.ts +6 -1
  54. package/src/analytics/useAnalytics.ts +22 -1
  55. package/src/coachmarks/runtime.ts +53 -17
  56. package/src/config/wireConfigFromEnv.ts +46 -2
  57. package/src/context/deviceId.ts +173 -0
  58. package/src/context/userContext.ts +18 -0
  59. package/src/index.ts +54 -2
  60. package/src/questionnaire/runtime.ts +13 -2
  61. package/src/questionnaire/useQuestionnaireGate.ts +11 -4
  62. package/src/revenuecat/index.ts +55 -0
  63. package/src/revenuecat/purchaseEvents.ts +167 -0
  64. package/src/revenuecat/revenueCatBridge.ts +221 -0
  65. package/src/revenuecat/types.ts +95 -0
  66. package/src/reviews/runtime.ts +153 -1
  67. package/src/reviews/transport.ts +21 -2
  68. package/src/reviews/useReviewGate.ts +14 -4
  69. package/src/session-analytics/lifecycle.ts +9 -2
  70. package/src/session-analytics/reportSessionStart.ts +15 -4
  71. package/src/session-analytics/useLifecycleEvents.ts +83 -27
  72. package/src/session-analytics/useSessionStart.ts +37 -1
  73. package/src/types.ts +41 -4
@@ -128,6 +128,48 @@ export const buildEventsRequest = (
128
128
  }
129
129
  };
130
130
 
131
+ /** RN sets this global; absent under node/SSR. Read defensively inside {@link warnOnSkippedEvents}. */
132
+ declare const __DEV__: boolean | undefined;
133
+
134
+ /**
135
+ * Read the `/v1/events` ACK body and warn (dev builds only) when the server DISCARDED events.
136
+ *
137
+ * The endpoint answers HTTP **200** with `{ ok, written, skipped }` — an event it refuses (a missing
138
+ * `session_id`, a malformed payload) is counted in `skipped`, never surfaced in the status code. Every
139
+ * send path here reads `res.ok` alone, so a whole batch can evaporate behind a green response. This
140
+ * consumes the body of the PERSISTENT paths (the offline queue + session-start) and names the count.
141
+ *
142
+ * LOG ONLY: returns immediately, never throws, and never influences retry / dequeue / return values.
143
+ * A response with no usable `.json` (an old server, a test mock) is silently ignored.
144
+ *
145
+ * DEV-GATED FIRST: the `__DEV__` check is the FIRST statement, before the body is even looked at.
146
+ * The check used to sit inside the `.then`, so a release build parsed the JSON of every
147
+ * persistent-path POST to build a warning no one would ever read. Nothing here runs in production.
148
+ */
149
+ export const warnOnSkippedEvents = (res: unknown): void => {
150
+ if (typeof __DEV__ === "undefined" || !__DEV__) return;
151
+ if (typeof console === "undefined" || !console.warn) return;
152
+ try {
153
+ const json = (res as { json?: () => Promise<unknown> } | null | undefined)?.json;
154
+ if (typeof json !== "function") return;
155
+ void Promise.resolve(json.call(res))
156
+ .then((body) => {
157
+ const skipped = (body as { skipped?: unknown } | null | undefined)?.skipped;
158
+ if (typeof skipped !== "number" || skipped <= 0) return;
159
+ console.warn(
160
+ `[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${skipped} event(s) ` +
161
+ "(skipped in the response body) — they are gone, not retried. The usual cause is an " +
162
+ "event with a missing or empty session_id.",
163
+ );
164
+ })
165
+ .catch(() => {
166
+ // Unreadable / already-consumed body — best-effort logging, swallow.
167
+ });
168
+ } catch {
169
+ // A hostile response object — swallow.
170
+ }
171
+ };
172
+
131
173
  /**
132
174
  * POST one or more client events, fire-and-forget. A missing/invalid target, a build error,
133
175
  * a missing `fetch`, or a network failure is swallowed — the call returns immediately and the
@@ -42,7 +42,12 @@ export interface ScreenTrackerOptions {
42
42
  * exposes. When omitted, the tracker still de-dups and fires `onScreen`, but sends nothing.
43
43
  */
44
44
  target?: { serverUrl: string; apiKey: string };
45
- /** The onboarding/session id to correlate screen views with, when known. */
45
+ /**
46
+ * The onboarding/session id to correlate screen views with, when known. Omitting it no longer
47
+ * means the view goes out WITHOUT a `session_id` (the server requires one and drops the event
48
+ * behind an HTTP 200 — that is why screen tracking silently produced nothing for a host that
49
+ * never mounted the lifecycle hook). `reportAppEvent` falls back to the current per-open id.
50
+ */
46
51
  sessionId?: string;
47
52
  /** A stable, non-PII device id — groups a device's sessions server-side. */
48
53
  deviceKey?: string;
@@ -11,7 +11,7 @@
11
11
  * analytics.track("content_share", { source: "feed" });
12
12
  * // ...on reconnect: analytics.notifyOnline();
13
13
  */
14
- import { useRef } from "react";
14
+ import { useEffect, useRef } from "react";
15
15
 
16
16
  import {
17
17
  createAnalytics,
@@ -38,5 +38,26 @@ export const useAnalytics = (
38
38
  prevKeys.current = currentKeys;
39
39
  ref.current = createAnalytics(config, options);
40
40
  }
41
+
42
+ // A LATE-ARRIVING host device key must still reach the instance.
43
+ //
44
+ // `createAnalytics` copies `config.userContext` into a closure at construction and never re-reads
45
+ // the prop, and `config.userContext.deviceKey` is deliberately NOT part of `currentKeys` (rebuilding
46
+ // the instance would throw away the event queue's pending buffer). So a host that hydrates its
47
+ // device id asynchronously — an AsyncStorage read that resolves after first render — used to be
48
+ // stamped with the kit's auto-minted `wdev_*` id FOREVER, while a sibling `useWireActivation`
49
+ // (which does key on it) rebuilt and used the real one. One install, two `device_key` values, in
50
+ // the same app, on the key every gating rule and the purchase↔onboarding join reads.
51
+ //
52
+ // `setUserContext` is the non-destructive seam for exactly this: it updates the bound context in
53
+ // place, so subsequent events carry the host key with no queue rebuild.
54
+ const hostDeviceKey =
55
+ typeof config.userContext?.deviceKey === "string" && config.userContext.deviceKey.trim()
56
+ ? config.userContext.deviceKey.trim()
57
+ : undefined;
58
+ useEffect(() => {
59
+ if (hostDeviceKey) ref.current?.setUserContext({ deviceKey: hostDeviceKey });
60
+ }, [hostDeviceKey]);
61
+
41
62
  return ref.current;
42
63
  };
@@ -8,35 +8,71 @@
8
8
  * the root by `CoachmarkProvider`. Singletons let a hook read the gate without
9
9
  * every call site threading the storage through props. The provider is still the
10
10
  * single writer — it calls `setCoachmarkStorage` / `setCoachmarkTesting` on mount.
11
+ *
12
+ * ── WHY A globalThis SLOT, NOT PLAIN MODULE VARIABLES ─────────────────────────────────────────
13
+ * These three values used to be plain module `let`s, and this module is inlined by tsup into FOUR
14
+ * dist bundles (`./coachmarks`, `./reviews`, `./questionnaire`, `./showcase`). Under `dist`
15
+ * resolution — node `import`/`require`, SSR, jest, RN-web, anything that is not Metro — a host that
16
+ * mounts `CoachmarkProvider` from `@wireai/activation/coachmarks` wrote the COACHMARKS copy, while
17
+ * `useReviewGate` from `@wireai/activation/reviews` read the REVIEWS copy, which was still `null`.
18
+ * Everything downstream then failed silently: `readInt(null, …)` returns 0, so `bumpSessionCount`
19
+ * returns 1 forever and the fail-closed `minSessions: 2` default is unsatisfiable (the gate NEVER
20
+ * fires); the `seen` once-gate never persists; and a tenant's coachmarks kill switch never reaches
21
+ * the reviews bundle. Metro masked it by collapsing every subpath back to one `src/` file through
22
+ * the `react-native` export condition, which is a bundler accident, not a guarantee.
23
+ *
24
+ * Same remedy this repo already applies to `currentSession`, the auto device-key registry and the
25
+ * activation revalidation counter: ONE record in a `globalThis` slot keyed by `Symbol.for(...)`, so
26
+ * every inlined copy of this module addresses the SAME state.
11
27
  */
12
28
  import type { CoachmarkStorage } from "./types";
13
29
 
14
- let storageSingleton: CoachmarkStorage | null = null;
15
- let testing = false;
16
- // The coachmarks feature kill switch (from GET /v1/features). Default true = fail-open: with no
17
- // flags fetched, coachmarks behave exactly as before. CoachmarkProvider writes it from the
18
- // resolved flags. Disabled → the tour never ARMS and the overlay `show()` is a no-op, so nothing
19
- // paints and — critically — no once-gate is written, so re-enabling replays the tour correctly.
20
- let coachmarksEnabled = true;
30
+ /** Well-known key into the runtime-global symbol registry — one coachmark runtime across bundles. */
31
+ const COACHMARK_RUNTIME_SLOT: unique symbol = Symbol.for("@wireai/activation:coachmarkRuntime");
32
+
33
+ /** The shared runtime: the provider-injected storage, the QA replay flag, and the kill switch. */
34
+ type CoachmarkRuntime = {
35
+ storage: CoachmarkStorage | null;
36
+ testing: boolean;
37
+ // The coachmarks feature kill switch (from GET /v1/features). Default true = fail-open: with no
38
+ // flags fetched, coachmarks behave exactly as before. CoachmarkProvider writes it from the
39
+ // resolved flags. Disabled → the tour never ARMS and the overlay `show()` is a no-op, so nothing
40
+ // paints and — critically — no once-gate is written, so re-enabling replays the tour correctly.
41
+ coachmarksEnabled: boolean;
42
+ };
43
+
44
+ type GlobalWithCoachmarkRuntime = typeof globalThis & {
45
+ [COACHMARK_RUNTIME_SLOT]?: CoachmarkRuntime;
46
+ };
47
+
48
+ const runtimeGlobal = globalThis as GlobalWithCoachmarkRuntime;
49
+
50
+ const coachmarkRuntime = (): CoachmarkRuntime => {
51
+ const existing = runtimeGlobal[COACHMARK_RUNTIME_SLOT];
52
+ if (existing) return existing;
53
+ const created: CoachmarkRuntime = { storage: null, testing: false, coachmarksEnabled: true };
54
+ runtimeGlobal[COACHMARK_RUNTIME_SLOT] = created;
55
+ return created;
56
+ };
21
57
 
22
58
  /**
23
59
  * Set the coachmarks master switch (from the resolved feature flags). Default true (fail-open).
24
60
  * Written by CoachmarkProvider; read imperatively by the tour arm + the overlay store.
25
61
  */
26
62
  export const setCoachmarksEnabled = (value: boolean): void => {
27
- coachmarksEnabled = value;
63
+ coachmarkRuntime().coachmarksEnabled = value;
28
64
  };
29
65
 
30
66
  /** Whether the coachmarks module is enabled. False → tours/overlays are silently skipped. */
31
- export const areCoachmarksEnabled = (): boolean => coachmarksEnabled;
67
+ export const areCoachmarksEnabled = (): boolean => coachmarkRuntime().coachmarksEnabled;
32
68
 
33
69
  /** Set (or clear) the injected sync gate storage. Called by CoachmarkProvider. */
34
70
  export const setCoachmarkStorage = (storage: CoachmarkStorage | null): void => {
35
- storageSingleton = storage;
71
+ coachmarkRuntime().storage = storage;
36
72
  };
37
73
 
38
74
  /** The currently-injected gate storage, or null if no provider is mounted. */
39
- export const getCoachmarkStorage = (): CoachmarkStorage | null => storageSingleton;
75
+ export const getCoachmarkStorage = (): CoachmarkStorage | null => coachmarkRuntime().storage;
40
76
 
41
77
  /**
42
78
  * Toggle the global QA replay flag. When true, EVERY seen-gate reads as unseen
@@ -44,11 +80,11 @@ export const getCoachmarkStorage = (): CoachmarkStorage | null => storageSinglet
44
80
  * mount. The app flips ONE boolean to QA the whole coachmark surface.
45
81
  */
46
82
  export const setCoachmarkTesting = (value: boolean): void => {
47
- testing = value;
83
+ coachmarkRuntime().testing = value;
48
84
  };
49
85
 
50
86
  /** Whether replay-everything QA mode is on. */
51
- export const isCoachmarkTesting = (): boolean => testing;
87
+ export const isCoachmarkTesting = (): boolean => coachmarkRuntime().testing;
52
88
 
53
89
  /** Gate key for a tour: `wire_coachmark_<tourId>_seen`. */
54
90
  export const coachmarkGateKey = (tourId: string): string =>
@@ -73,8 +109,8 @@ export const hasSeenGate = (
73
109
  storageOverride?: CoachmarkStorage | null,
74
110
  testingOverride?: boolean,
75
111
  ): boolean => {
76
- if (testingOverride ?? testing) return false;
77
- const storage = storageOverride ?? storageSingleton;
112
+ if (testingOverride ?? isCoachmarkTesting()) return false;
113
+ const storage = storageOverride ?? getCoachmarkStorage();
78
114
  if (!storage) return false;
79
115
  try {
80
116
  return storage.getItem(key) === SEEN_VALUE;
@@ -92,8 +128,8 @@ export const markSeenGate = (
92
128
  storageOverride?: CoachmarkStorage | null,
93
129
  testingOverride?: boolean,
94
130
  ): void => {
95
- if (testingOverride ?? testing) return;
96
- const storage = storageOverride ?? storageSingleton;
131
+ if (testingOverride ?? isCoachmarkTesting()) return;
132
+ const storage = storageOverride ?? getCoachmarkStorage();
97
133
  if (!storage) return;
98
134
  try {
99
135
  storage.setItem(key, SEEN_VALUE);
@@ -12,12 +12,31 @@
12
12
  * return <WireOnboarding config={config} ... />;
13
13
  *
14
14
  * `appId` defaults to `EXPO_PUBLIC_WIREAI_APP_ID` (or "default"); pass overrides to
15
- * set `appId`/`metadata` or to substitute the key/URL programmatically.
15
+ * set `appId`/`metadata` or to substitute the key/URL programmatically. `appVersion`
16
+ * defaults to `detectAppVersion()` so every surface built from this config carries it.
16
17
  */
18
+ import { detectAppVersion } from "../device/appVersion";
17
19
  import type { WireOnboardingConfig } from "../types";
18
20
 
19
21
  export type WireConfigOverrides = Partial<WireOnboardingConfig>;
20
22
 
23
+ /** The env vars this helper reads. Exported so a host preflight can assert them itself. */
24
+ export const WIRE_ENV_VARS = [
25
+ "EXPO_PUBLIC_WIREAI_API_KEY",
26
+ "EXPO_PUBLIC_WIREAI_SERVER_URL",
27
+ "EXPO_PUBLIC_WIREAI_APP_ID",
28
+ ] as const;
29
+
30
+ /** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
31
+ declare const __DEV__: boolean | undefined;
32
+
33
+ /** Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests. */
34
+ const warnInDev = (message: string): void => {
35
+ if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
36
+ console.warn(message);
37
+ }
38
+ };
39
+
21
40
  /**
22
41
  * Minimal `process.env` declaration so the kit stays RN-pure (no `@types/node`).
23
42
  * In RN/Expo, `process.env.EXPO_PUBLIC_*` is provided/inlined by Metro at build
@@ -41,12 +60,37 @@ export const wireConfigFromEnv = (
41
60
  overrides?.appId ?? process.env.EXPO_PUBLIC_WIREAI_APP_ID ?? "default";
42
61
 
43
62
  // Missing transport → null, so the host gates on one falsy check.
44
- if (!apiKey || !serverUrl) return null;
63
+ //
64
+ // The `null` return is the CORRECT contract and hosts rely on it — but it is also the quietest
65
+ // failure the kit has: a missing EAS env var makes this return null, and then the facade never
66
+ // constructs, `useLifecycleEvents` no-ops on `!cfg?.serverUrl`, and every gate returns null. Wire
67
+ // is 100% off, with no error anywhere. Name it once in dev, and name WHICH var is missing.
68
+ if (!apiKey || !serverUrl) {
69
+ const missing = [
70
+ apiKey ? undefined : "EXPO_PUBLIC_WIREAI_API_KEY",
71
+ serverUrl ? undefined : "EXPO_PUBLIC_WIREAI_SERVER_URL",
72
+ ].filter(Boolean);
73
+ warnInDev(
74
+ `[wireai] wireConfigFromEnv() returned null: ${missing.join(" and ")} ${
75
+ missing.length > 1 ? "are" : "is"
76
+ } missing. The kit is now FULLY DISABLED (no onboarding, no analytics, no gates) and nothing ` +
77
+ "else will report an error. Set the var(s) in your env / EAS secrets.",
78
+ );
79
+ return null;
80
+ }
81
+
82
+ // `appVersion` is part of `WireOnboardingConfig`, but this helper could not carry it — so a host
83
+ // that built its config here forwarded `config.appVersion === undefined` to every surface. The
84
+ // facade and the lifecycle path each auto-detect their own fallback; `createWireActivation` does
85
+ // not, so every `wire.track` event shipped with no `app_version` at all. Detect once here (an
86
+ // explicit override still wins) and the whole config carries it.
87
+ const appVersion = overrides?.appVersion ?? detectAppVersion();
45
88
 
46
89
  return {
47
90
  apiKey,
48
91
  serverUrl,
49
92
  appId,
93
+ ...(appVersion ? { appVersion } : {}),
50
94
  ...(overrides?.metadata ? { metadata: overrides.metadata } : {}),
51
95
  };
52
96
  };
@@ -41,3 +41,176 @@ export const mintDeviceId = (): string => {
41
41
  const time = Date.now().toString(36);
42
42
  return `${AUTO_DEVICE_ID_PREFIX}${time}_${randomChunk()}${randomChunk()}`;
43
43
  };
44
+
45
+ // ── The ONE auto device key per install ──────────────────────────────────────────────────────
46
+ //
47
+ // WHY A REGISTRY AND NOT A `let` PER FACTORY: `createAnalytics` and `createWireActivation` each
48
+ // used to mint their OWN id synchronously and then race a storage read to overwrite it. A host that
49
+ // creates BOTH (the documented wiring: a façade for `track`/`screen`, an activation instance for the
50
+ // gate-firing `wire.track`) therefore had TWO auto ids for ONE install. Every event carried whichever
51
+ // id its own surface minted, so the `device_key` the server groups a device's sessions under — the
52
+ // key `min_sessions`, A/B arm stickiness, and the purchase↔onboarding join all read — SPLIT in two.
53
+ // On a first run both also wrote their own id to the same storage slot, so which one survived was a
54
+ // coin flip. Same failure class as joining two event families on disjoint id spaces: no error, just
55
+ // halved counts and a join that misses.
56
+ //
57
+ // The fix is the pattern this repo already uses for `currentSession` and `activation revalidation`:
58
+ // ONE value in a `globalThis` slot keyed by `Symbol.for(...)`, so every inlined copy of this module
59
+ // (tsup duplicates modules across the `.` / `./analytics` bundles) addresses the SAME registry.
60
+ // Keyed by `appId` so two tenants in one process never share an id.
61
+ //
62
+ // RESIDUAL WINDOW: the storage read is async, so an event emitted in the milliseconds before
63
+ // hydration completes carries the freshly minted id rather than the persisted one. The registry makes
64
+ // every surface agree on WHICH id that is; it does not make the read sync. A caller that can afford to
65
+ // wait (the lifecycle hook's mount effect — see `hydrateAutoDeviceKey`) should await instead: its two
66
+ // events are the ONLY ones the server counts `min_sessions` from, so a per-launch id there is not a
67
+ // millisecond of noise, it is a counter that can never exceed 1.
68
+
69
+ /** Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle. */
70
+ const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoDeviceKeys");
71
+
72
+ /** The shared registry: the live id per `appId`, the set of appIds whose hydration already started,
73
+ * and the in-flight (or settled) hydration promise per `appId` so a waiter can join it. */
74
+ type AutoDeviceKeyRegistry = {
75
+ keys: Map<string, string>;
76
+ hydrating: Set<string>;
77
+ pending?: Map<string, Promise<string>>;
78
+ };
79
+
80
+ type GlobalWithDeviceKeys = typeof globalThis & {
81
+ [AUTO_DEVICE_KEY_SLOT]?: AutoDeviceKeyRegistry;
82
+ };
83
+
84
+ const deviceKeyGlobal = globalThis as GlobalWithDeviceKeys;
85
+
86
+ const autoDeviceKeyRegistry = (): AutoDeviceKeyRegistry => {
87
+ const existing = deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT];
88
+ if (existing) return existing;
89
+ const created: AutoDeviceKeyRegistry = { keys: new Map(), hydrating: new Set() };
90
+ deviceKeyGlobal[AUTO_DEVICE_KEY_SLOT] = created;
91
+ return created;
92
+ };
93
+
94
+ /** The persistence subset {@link resolveAutoDeviceKey} needs (a strict subset of `WireOnboardingStorage`). */
95
+ export type DeviceKeyStorage = {
96
+ getItem(key: string): Promise<string | null>;
97
+ setItem(key: string, value: string): Promise<void>;
98
+ };
99
+
100
+ /** Options for {@link resolveAutoDeviceKey}. Omitting `storage` gives a PROCESS-scoped id, not a
101
+ * per-install one — see the caller notes: a caller with no persistence must decide whether a
102
+ * per-launch id is better or worse than no id for its metric. */
103
+ export interface ResolveAutoDeviceKeyOptions {
104
+ /** Tenant/app id — namespaces both the registry entry and the storage slot. */
105
+ appId?: string;
106
+ /** Host persistence. Present → the id survives launches. Absent → process-scoped only. */
107
+ storage?: DeviceKeyStorage;
108
+ }
109
+
110
+ /**
111
+ * Start (or join) the SINGLE-FLIGHT storage read for `appId` and resolve to the id it settles on.
112
+ * The promise is parked on the registry so a later `hydrateAutoDeviceKey` awaits the SAME read
113
+ * instead of starting a second one. Never rejects: any storage failure resolves to the live id.
114
+ */
115
+ const startHydration = (
116
+ registry: AutoDeviceKeyRegistry,
117
+ appId: string,
118
+ storage: DeviceKeyStorage,
119
+ minted: string,
120
+ ): Promise<string> => {
121
+ if (!registry.pending) registry.pending = new Map();
122
+ const existing = registry.pending.get(appId);
123
+ if (existing) return existing;
124
+
125
+ const slot = deviceIdStorageKey(appId);
126
+ const settled = (): string => registry.keys.get(appId) ?? minted;
127
+ let run: Promise<string>;
128
+ try {
129
+ run = Promise.resolve(storage.getItem(slot))
130
+ .then((saved) => {
131
+ const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : undefined;
132
+ if (persisted) {
133
+ registry.keys.set(appId, persisted);
134
+ return persisted;
135
+ }
136
+ // First run on this install: persist the id we just minted so the next launch adopts it.
137
+ return Promise.resolve(storage.setItem(slot, minted)).then(settled, settled);
138
+ })
139
+ .catch(settled);
140
+ } catch {
141
+ // A storage adapter that throws synchronously — degrade to the in-memory id.
142
+ run = Promise.resolve(settled());
143
+ }
144
+ registry.pending.set(appId, run);
145
+ return run;
146
+ };
147
+
148
+ /**
149
+ * The ONE auto-minted `device_key` for an install, shared by every kit surface.
150
+ *
151
+ * SYNCHRONOUS by contract (a fire-and-forget event path cannot await): returns the current live id
152
+ * immediately, minting one on first call. When `storage` is supplied it also kicks off a SINGLE
153
+ * hydration per `appId` that adopts the persisted id (or persists the freshly minted one). Callers
154
+ * should call this per EVENT rather than caching the return value, so an event built after hydration
155
+ * carries the persisted id.
156
+ *
157
+ * A host-supplied `deviceKey` always wins — callers must short-circuit before reaching this.
158
+ * Never throws: a missing, hung, or rejecting storage adapter degrades to the in-memory id.
159
+ */
160
+ export const resolveAutoDeviceKey = (opts: ResolveAutoDeviceKeyOptions = {}): string => {
161
+ const registry = autoDeviceKeyRegistry();
162
+ const appId = opts.appId ?? "default";
163
+
164
+ let id = registry.keys.get(appId);
165
+ if (!id) {
166
+ id = mintDeviceId();
167
+ registry.keys.set(appId, id);
168
+ }
169
+
170
+ const storage = opts.storage;
171
+ // Single-flight: the FIRST caller with storage owns hydration for this appId; later callers just
172
+ // read whatever the registry currently holds.
173
+ if (storage && !registry.hydrating.has(appId)) {
174
+ registry.hydrating.add(appId);
175
+ void startHydration(registry, appId, storage, id);
176
+ }
177
+
178
+ return registry.keys.get(appId) ?? id;
179
+ };
180
+
181
+ /**
182
+ * The AWAITABLE sibling of {@link resolveAutoDeviceKey}: resolve to the auto `device_key` AFTER the
183
+ * persisted id has been read back (or written, on a first run), so the caller stamps the id this
184
+ * install will keep rather than the one that was minted a millisecond ago.
185
+ *
186
+ * WHY IT EXISTS: `resolveAutoDeviceKey` is synchronous by contract, so a caller firing at mount got
187
+ * the freshly minted id and the persisted one landed milliseconds later. For most events that is
188
+ * noise. For `app.session_started` it is the whole metric: the server computes `min_sessions` by
189
+ * counting distinct opens grouped by `device_key`, so a per-launch key there makes the counter
190
+ * structurally incapable of exceeding 1, and it splits `first_open` off from every event that
191
+ * follows it. Only a caller that can afford one storage read should use this; the fire-and-forget
192
+ * event paths must stay on the sync function.
193
+ *
194
+ * Never throws or rejects: a missing, hung, or rejecting adapter resolves to the in-memory id, and
195
+ * with no `storage` it resolves immediately (there is nothing to hydrate from).
196
+ */
197
+ export const hydrateAutoDeviceKey = async (
198
+ opts: ResolveAutoDeviceKeyOptions = {},
199
+ ): Promise<string> => {
200
+ // Mint + register synchronously first, so a waiter and a concurrent sync caller share ONE id.
201
+ const id = resolveAutoDeviceKey(opts);
202
+ if (!opts.storage) return id;
203
+ const registry = autoDeviceKeyRegistry();
204
+ const appId = opts.appId ?? "default";
205
+ const pending = registry.pending?.get(appId);
206
+ if (pending) await pending;
207
+ return registry.keys.get(appId) ?? id;
208
+ };
209
+
210
+ /** Test-only: forget every auto id + hydration flag so a unit test starts from a clean registry. */
211
+ export const resetAutoDeviceKeys = (): void => {
212
+ const registry = autoDeviceKeyRegistry();
213
+ registry.keys.clear();
214
+ registry.hydrating.clear();
215
+ registry.pending?.clear();
216
+ };
@@ -207,6 +207,24 @@ export const clearUserContext = async (opts: ClearUserContextOptions = {}): Prom
207
207
  }
208
208
  };
209
209
 
210
+ /**
211
+ * The `userContext` value to hand `<WireOnboarding userContext={...} />` so an onboarding session
212
+ * and the app's later events (purchases, actions, screens) share ONE join key.
213
+ *
214
+ * WHY it exists as a named function instead of an inline object literal: the wire key is
215
+ * `device_key`, the prop-facing name is `deviceKey`, and the analytics surfaces auto-mint the value
216
+ * for you. A host that hand-writes `userContext={{ deviceKey }}` produces a bucket the server's
217
+ * device lookup does not read, and the resulting funnel is silently EMPTY rather than wrong. This is
218
+ * the one place that spelling is decided.
219
+ *
220
+ * Pass the SAME `deviceKey` you gave `createAnalytics` / `createWireActivation`. `session_id` is not
221
+ * a join key across those two families: an onboarding session id is the A2A `contextId` and an
222
+ * app-event session id is the per-open id, so intersecting them returns nothing.
223
+ */
224
+ export const activationJoinContext = (
225
+ deviceKey: string,
226
+ ): Record<string, string | number | boolean> => resolveUserContext({ deviceKey }).userContext ?? {};
227
+
210
228
  /** Options for {@link resolveUserContext}. */
211
229
  export interface ResolveUserContextOptions {
212
230
  /**
package/src/index.ts CHANGED
@@ -109,7 +109,12 @@ export type {
109
109
  } from "./features";
110
110
 
111
111
  // ─── Config + env helpers ─────────────────────────────────────────────────────
112
- export { wireConfigFromEnv } from "./config/wireConfigFromEnv";
112
+ export {
113
+ wireConfigFromEnv,
114
+ // The three `EXPO_PUBLIC_WIREAI_*` names, so a host preflight can assert its own env instead of
115
+ // discovering a missing var as a silently disabled kit (`wireConfigFromEnv` returns null).
116
+ WIRE_ENV_VARS,
117
+ } from "./config/wireConfigFromEnv";
113
118
  export type { WireConfigOverrides } from "./config/wireConfigFromEnv";
114
119
  export { isOnboardingEnabled } from "./config/onboardingFlag";
115
120
  export type { OnboardingFlagOptions } from "./config/onboardingFlag";
@@ -153,6 +158,7 @@ export {
153
158
  clearUserContext,
154
159
  clearPiiFromContext,
155
160
  analyticsUserIdStorageKey,
161
+ activationJoinContext,
156
162
  RESERVED_USER_CONTEXT_KEYS,
157
163
  EXTRA_KEY_PREFIX,
158
164
  } from "./context/userContext";
@@ -162,11 +168,30 @@ export type {
162
168
  ResolveUserContextOptions,
163
169
  ClearUserContextOptions,
164
170
  } from "./context/userContext";
165
- export { mintDeviceId, deviceIdStorageKey, AUTO_DEVICE_ID_PREFIX } from "./context/deviceId";
171
+ export {
172
+ mintDeviceId,
173
+ deviceIdStorageKey,
174
+ AUTO_DEVICE_ID_PREFIX,
175
+ // `resolveAutoDeviceKey` is the id `createAnalytics` / `createWireActivation` auto-mint and persist.
176
+ // It MUST be public: the documented purchase↔onboarding join is
177
+ // `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`, and a host that owns NO device
178
+ // id of its own had no way to obtain the key the analytics side was already stamping — so its
179
+ // purchase events carried `wdev_*` while its onboarding session carried no `device_key` at all, and
180
+ // the join returned the silent zero the README warns about.
181
+ resolveAutoDeviceKey,
182
+ // The awaitable sibling: resolves AFTER the persisted id has been read back, for a caller that can
183
+ // afford one storage read and must not stamp a key minted a millisecond ago (the lifecycle mount).
184
+ hydrateAutoDeviceKey,
185
+ resetAutoDeviceKeys,
186
+ } from "./context/deviceId";
187
+ export type { DeviceKeyStorage, ResolveAutoDeviceKeyOptions } from "./context/deviceId";
166
188
 
167
189
  // ─── Current per-open session registry (identify/app-events reuse the live session) ───
168
190
  export {
169
191
  getCurrentSessionId,
192
+ // Returns the registered per-open id, minting + registering one when no app-open has been
193
+ // registered yet — so no wire path can emit the empty `session_id` the server drops behind a 200.
194
+ ensureCurrentSessionId,
170
195
  setCurrentSessionId,
171
196
  resetCurrentSessionId,
172
197
  } from "./analytics/currentSession";
@@ -189,6 +214,33 @@ export {
189
214
  resetActivationRevalidation,
190
215
  } from "./activation";
191
216
 
217
+ // ─── RevenueCat (purchase funnel → the same events stream, joined on device_key) ──
218
+ export {
219
+ createRevenueCatBridge,
220
+ WIRE_PURCHASE_EVENTS,
221
+ PLAN_TIER_CONTEXT_KEY,
222
+ activeEntitlement,
223
+ describeEntitlement,
224
+ describeFailure,
225
+ describePackage,
226
+ isUserCancelled,
227
+ resolvePlanTier,
228
+ } from "./revenuecat";
229
+ export type {
230
+ RevenueCatBridge,
231
+ RevenueCatBridgeConfig,
232
+ PurchaseProps,
233
+ WirePurchaseEventName,
234
+ PlanTier,
235
+ RevenueCatCustomerInfoLike,
236
+ RevenueCatEntitlementLike,
237
+ RevenueCatErrorLike,
238
+ RevenueCatOfferingLike,
239
+ RevenueCatPackageLike,
240
+ RevenueCatProductLike,
241
+ RevenueCatSink,
242
+ } from "./revenuecat";
243
+
192
244
  // ─── Session mapping (one `app.session_started` per app-open → /v1/events) ─────
193
245
  export {
194
246
  reportSessionStart,
@@ -5,7 +5,14 @@
5
5
  * host that mounted CoachmarkProvider gets questionnaire gating for free - no second storage
6
6
  * to wire. Once-per-user is keyed on the questionnaire `id`.
7
7
  */
8
- export { resolveStorage, readInt, writeInt } from "../reviews/runtime";
8
+ export {
9
+ resolveStorage,
10
+ readInt,
11
+ writeInt,
12
+ bumpSessionCount,
13
+ currentOpenId,
14
+ warnMissingGateStorage,
15
+ } from "../reviews/runtime";
9
16
 
10
17
  /** Once-gate key. Keyed by app version when `oncePerVersion` is on, so a new release re-enables. */
11
18
  export const questionnaireSeenKey = (id: string, version?: string): string =>
@@ -15,6 +22,10 @@ export const questionnaireSeenKey = (id: string, version?: string): string =>
15
22
  export const questionnaireLastShownKey = (id: string): string =>
16
23
  `wire_questionnaire_${id}_last`;
17
24
 
18
- /** Session-count key, incremented once per gate mount, for the min-sessions rule. */
25
+ /** Session-count key, incremented once per APP-OPEN (not per mount), for the min-sessions rule. */
19
26
  export const questionnaireSessionsKey = (id: string): string =>
20
27
  `wire_questionnaire_${id}_sessions`;
28
+
29
+ /** Companion key holding the open id the counter was LAST incremented for (see `bumpSessionCount`). */
30
+ export const questionnaireSessionOpenKey = (id: string): string =>
31
+ `wire_questionnaire_${id}_open`;
@@ -20,11 +20,14 @@ import { useResolvedFeatures } from "../features/WireFeaturesProvider";
20
20
  import { sameDecision, shallowEqual } from "../reviews/equality";
21
21
  import { decideQuestionnaire, evaluateGate, resolveRules } from "./decision";
22
22
  import {
23
+ bumpSessionCount,
23
24
  questionnaireLastShownKey,
24
25
  questionnaireSeenKey,
26
+ questionnaireSessionOpenKey,
25
27
  questionnaireSessionsKey,
26
28
  readInt,
27
29
  resolveStorage,
30
+ warnMissingGateStorage,
28
31
  writeInt,
29
32
  } from "./runtime";
30
33
  import type {
@@ -71,13 +74,17 @@ export const useQuestionnaireGate = ({
71
74
  );
72
75
  const lastKey = questionnaireLastShownKey(config.id);
73
76
  const sessionsKey = questionnaireSessionsKey(config.id);
77
+ const sessionOpenKey = questionnaireSessionOpenKey(config.id);
74
78
 
75
- // Read (and bump) the session counter ONCE per mount: this mount is a new session.
79
+ // Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount see the same note
80
+ // in `useReviewGate`; `bumpSessionCount` keys off the live per-open session id, so a remount or a
81
+ // StrictMode double-invoke of this initializer reads the same number back instead of inflating it.
76
82
  const sessions = useState(() => {
77
83
  const store = resolveStorage(storage);
78
- const next = readInt(store, sessionsKey) + 1;
79
- writeInt(store, sessionsKey, next);
80
- return next;
84
+ // Same as `useReviewGate`: no storage means the counter never leaves 1, so the fail-closed
85
+ // minSessions rule is unsatisfiable and the gate silently never fires. Dev-only, once per process.
86
+ warnMissingGateStorage(store, "questionnaire");
87
+ return bumpSessionCount(store, sessionsKey, sessionOpenKey);
81
88
  })[0];
82
89
 
83
90
  // Gate the local rules behind an optional client-side timeout, so a reachable server gets a