@wireai/activation 0.9.2 → 0.10.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.
package/llms.txt CHANGED
@@ -23,6 +23,7 @@
23
23
  - `attributionMetadata(a)` → shape install/ad attribution into `config.metadata` (forwarded to the agent).
24
24
  - `reportClientEvent(target, event)` / `reportClientEvents` / `makeSessionId` (ROOT-exported, not a subpath) → report device-only funnel events. Contract: `POST {serverUrl}/v1/events`, header `Authorization: Bearer {apiKey}`, body `{ "events": [ ... ] }`; `target = { serverUrl, apiKey }` from the config. `<WireOnboarding>` does this automatically: `dropped` on unmount-without-complete, `client_fallback` on degrade-to-static. Hosts must not double-report fallback.
25
25
  - `deriveAnswers(messages)`, `themeFromBrand({ primary })`, `defaultIllustrations`, `DemoOnboarding` (dev/QA, no account).
26
+ - `useWireActivation({ serverUrl, apiKey, deviceKey? })` → `{ track, sessionId, revalidation }` (ROOT-exported; React-free factory `createWireActivation(config)`). `await track(name, meta?)` POSTs an `app_event` (`question_key=name`) under the current session, resolves `true` on 2xx, and bumps `revalidation`; list `revalidation` in a `fetchReviewDecision` / `fetchQuestionnaireDecision` effect's deps so a review/questionnaire gate re-fetches and fires off an in-app action instead of the host hand-rolling session-id + await-POST + revalidate.
26
27
 
27
28
  ## Files
28
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
6
6
  "author": "Malik Chohra <malik@getwireai.com>",
@@ -0,0 +1,24 @@
1
+ /**
2
+ * activation — the kit-owned `wire.track()` + `useWireActivation()` consolidation surface.
3
+ *
4
+ * The thin, reusable API that lets a consumer report a trigger action and re-fire the review /
5
+ * questionnaire gates WITHOUT hand-rolling an awaitable POST, its own session id, or an
6
+ * await-then-bump revalidation dance. Built on the existing events transport + the canonical
7
+ * `getCurrentSessionId()`. Re-exported from the main `@wireai/activation` barrel.
8
+ */
9
+
10
+ // ─── The pure factory (React-free) ─────────────────────────────────────────────
11
+ export { createWireActivation } from "./wireActivation";
12
+ export type { WireActivation, WireActivationConfig } from "./wireActivation";
13
+
14
+ // ─── The thin optional React hook (+ the standalone revalidation hook) ─────────
15
+ export { useWireActivation, useActivationRevalidation } from "./useWireActivation";
16
+ export type { UseWireActivation } from "./useWireActivation";
17
+
18
+ // ─── The revalidation pub/sub (cross-bundle-safe; the gates subscribe to it) ───
19
+ export {
20
+ bumpActivationRevalidation,
21
+ subscribeActivationRevalidation,
22
+ getActivationRevalidationVersion,
23
+ resetActivationRevalidation,
24
+ } from "./revalidation";
@@ -0,0 +1,88 @@
1
+ /**
2
+ * activation revalidation — a tiny kit-owned pub/sub the review / questionnaire decision fetches
3
+ * subscribe to, so they RE-FETCH their server decision AFTER a trigger action, not only on mount.
4
+ *
5
+ * WHY THIS EXISTS (subsumes Morrow's hand-rolled `activation-revalidation.ts`): a host mounts the
6
+ * review / questionnaire gate on the home screen and fetches its `/decision` verdict once, in a
7
+ * mount-scoped effect. But the screen stays mounted while the user goes elsewhere to perform the
8
+ * triggering action (journal a win, complete a task); the server's firing rule is trigger-based AND
9
+ * session-scoped, so the mount fetch ran BEFORE the trigger event existed and never re-runs. Bumping
10
+ * this store after the action POSTs re-runs the gate's decision fetch (it lists the version in its
11
+ * deps) → the server now sees the trigger → `{fire:true}`.
12
+ *
13
+ * `wire.track()` bumps this automatically on a successful POST, so a consumer no longer hand-rolls
14
+ * an await-then-bump: it subscribes (via {@link useActivationRevalidation} in `useWireActivation`)
15
+ * and re-fetches when the version changes.
16
+ *
17
+ * ── WHY A globalThis SLOT, NOT MODULE-LOCAL STATE (same reasoning as currentSession) ─────────────
18
+ * This module can be reached from more than one package entry (the main `.` barrel exposes the
19
+ * revalidation surface; the gates live under the `./reviews` / `./questionnaire` subpaths). Under
20
+ * `dist` resolution tsup inlines a SEPARATE copy of a module into each bundle, so plain module-local
21
+ * `version` + `listeners` would give the BUMPER (main entry, via `wire.track`) and a SUBSCRIBER a
22
+ * different store — the bump would never reach the listener, exactly the defect-B failure mode. So
23
+ * the ONE store lives in a `globalThis` slot keyed by `Symbol.for(...)`: every inlined copy resolves
24
+ * the same symbol and shares one store, on Hermes/RN, Node and SSR alike.
25
+ */
26
+
27
+ /** Well-known key into the runtime-global symbol registry — one shared store across every bundle. */
28
+ const REVALIDATION_SLOT: unique symbol = Symbol.for(
29
+ "@wireai/activation:activationRevalidation",
30
+ );
31
+
32
+ /** The shared store: a monotonic version + the set of subscriber callbacks. */
33
+ type RevalidationStore = { version: number; listeners: Set<() => void> };
34
+
35
+ type GlobalWithSlot = typeof globalThis & {
36
+ [REVALIDATION_SLOT]?: RevalidationStore;
37
+ };
38
+
39
+ const globalSlot = globalThis as GlobalWithSlot;
40
+
41
+ /** The one shared store, lazily created on the runtime global (never a second copy per bundle). */
42
+ const store = (): RevalidationStore => {
43
+ const existing = globalSlot[REVALIDATION_SLOT];
44
+ if (existing) return existing;
45
+ const created: RevalidationStore = { version: 0, listeners: new Set() };
46
+ globalSlot[REVALIDATION_SLOT] = created;
47
+ return created;
48
+ };
49
+
50
+ /**
51
+ * Signal every activation subscriber to re-fetch its server decision. `wire.track()` calls this on a
52
+ * successful POST (await the post first so the re-fetch sees the event). A throwing subscriber never
53
+ * breaks the notify loop — each listener is isolated.
54
+ */
55
+ export const bumpActivationRevalidation = (): void => {
56
+ const s = store();
57
+ s.version += 1;
58
+ // Iterate a snapshot so a listener that (un)subscribes during notify never corrupts the walk.
59
+ for (const listener of Array.from(s.listeners)) {
60
+ try {
61
+ listener();
62
+ } catch {
63
+ // A subscriber that throws must not stop the others (fail-safe).
64
+ }
65
+ }
66
+ };
67
+
68
+ /**
69
+ * Subscribe to revalidation. Returns an unsubscribe fn. Shaped for `useSyncExternalStore`
70
+ * (see {@link useActivationRevalidation} in `useWireActivation`).
71
+ */
72
+ export const subscribeActivationRevalidation = (listener: () => void): (() => void) => {
73
+ const s = store();
74
+ s.listeners.add(listener);
75
+ return () => {
76
+ s.listeners.delete(listener);
77
+ };
78
+ };
79
+
80
+ /** The current revalidation version — include it in a decision-fetch effect's deps to re-fetch on bump. */
81
+ export const getActivationRevalidationVersion = (): number => store().version;
82
+
83
+ /** Test-only: reset the shared store between cases (clears version + listeners). */
84
+ export const resetActivationRevalidation = (): void => {
85
+ const s = store();
86
+ s.version = 0;
87
+ s.listeners.clear();
88
+ };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * useWireActivation — a THIN optional React hook over {@link createWireActivation}.
3
+ *
4
+ * One import gives a consumer everything the activation consolidation owns: `track` (awaitable POST
5
+ * that auto-revalidates), the CURRENT `sessionId`, and a `revalidation` counter that ticks whenever
6
+ * a `track()` (from anywhere) succeeds — subscribe to re-render, then re-fetch your gate decision.
7
+ *
8
+ * const { track, sessionId, revalidation } = useWireActivation({ serverUrl, apiKey });
9
+ * // fetch the gate decision on mount AND whenever revalidation ticks:
10
+ * useEffect(() => { fetchReviewDecision(target, { sessionId, deviceKey }).then(setDecision); },
11
+ * [revalidation]);
12
+ * // on the trigger action:
13
+ * await track("journal_done");
14
+ *
15
+ * Mirrors the existing `createAnalytics` / `useAnalytics` (and `createScreenTracker` /
16
+ * `useScreenTracking`) split: the factory stays React-free; this is the glue. The instance is built
17
+ * once per mount and held in a ref, so re-renders never rebuild it or re-resolve the device key.
18
+ */
19
+ import { useRef, useSyncExternalStore } from "react";
20
+
21
+ import {
22
+ getActivationRevalidationVersion,
23
+ subscribeActivationRevalidation,
24
+ } from "./revalidation";
25
+ import {
26
+ createWireActivation,
27
+ type WireActivation,
28
+ type WireActivationConfig,
29
+ } from "./wireActivation";
30
+
31
+ /**
32
+ * Subscribe a component to decision revalidation. Returns the current version; list it in a
33
+ * decision-fetch effect's deps so a `bumpActivationRevalidation()` (which a successful `track` does)
34
+ * re-runs the fetch. The kit-owned replacement for a host's hand-rolled `useActivationRevalidation`.
35
+ */
36
+ export const useActivationRevalidation = (): number =>
37
+ useSyncExternalStore(
38
+ subscribeActivationRevalidation,
39
+ getActivationRevalidationVersion,
40
+ getActivationRevalidationVersion,
41
+ );
42
+
43
+ /** What {@link useWireActivation} returns: the awaitable `track`, the live `sessionId`, and the tick. */
44
+ export type UseWireActivation = {
45
+ /** Awaitable action report that auto-revalidates on success (see {@link WireActivation.track}). */
46
+ track: WireActivation["track"];
47
+ /** The CURRENT per-open session id (read fresh each render), or `undefined`. */
48
+ sessionId: string | undefined;
49
+ /** Monotonic counter that increments on every successful `track()` — a re-fetch trigger. */
50
+ revalidation: number;
51
+ };
52
+
53
+ /**
54
+ * Build a per-mount activation instance. `config` is read once at first render (the instance is
55
+ * stable for the component's lifetime, keyed on the transport creds + device key); the returned
56
+ * `revalidation` re-renders the component whenever any `track()` succeeds.
57
+ */
58
+ export const useWireActivation = (config: WireActivationConfig): UseWireActivation => {
59
+ const ref = useRef<WireActivation | undefined>(undefined);
60
+ const prevKeys = useRef<string>("");
61
+
62
+ const currentKeys = `${config.serverUrl}|${config.apiKey}|${config.appId}|${config.deviceKey}`;
63
+ if (!ref.current || prevKeys.current !== currentKeys) {
64
+ prevKeys.current = currentKeys;
65
+ ref.current = createWireActivation(config);
66
+ }
67
+
68
+ const revalidation = useActivationRevalidation();
69
+ return { track: ref.current.track, sessionId: ref.current.sessionId, revalidation };
70
+ };
@@ -0,0 +1,156 @@
1
+ /**
2
+ * wireActivation — the kit-owned `wire.track()` + session accessor + revalidation surface.
3
+ *
4
+ * WHAT IT CONSOLIDATES (subsumes Morrow's workarounds): a consumer used to hand-roll three things
5
+ * because the kit didn't own them — an awaitable action POST (`wireTrackActionAwait`), its OWN
6
+ * per-open session id (`getWireSessionId`, minted because the kit's `getCurrentSessionId` desynced
7
+ * cross-bundle — defect B), and an await-then-bump revalidation dance (`bumpActivationDecision`).
8
+ * Defect B is fixed (PR #48: `getCurrentSessionId` is a `globalThis` singleton, reliable under
9
+ * `dist`), so the kit can now own all three behind one thin surface:
10
+ *
11
+ * const wire = createWireActivation({ serverUrl, apiKey }); // or useWireActivation(...)
12
+ * await wire.track("journal_done"); // awaitable POST + auto-revalidate
13
+ *
14
+ * `track` POSTs `event_type='app_event'`, `question_key=<name>` (the EXACT string a review /
15
+ * questionnaire firing TRIGGER matches on) under the CURRENT `getCurrentSessionId()` — the same id
16
+ * the gates pass to their `/decision` fetch, so the server's session-scoped trigger rule agrees —
17
+ * with `user_context.device_key` for the min-sessions / arm-assignment lookups. On a successful POST
18
+ * it bumps decision revalidation so a subscribed gate re-fetches and can fire.
19
+ *
20
+ * ADDITIVE + built on existing primitives: it reuses the events transport
21
+ * (`reportClientEventAwait`, the awaitable sibling of `reportClientEvents` — ONE `/v1/events` path)
22
+ * and `getCurrentSessionId` — it introduces NO second session concept and duplicates no POST path.
23
+ * React-free (the optional React glue is the thin `useWireActivation` hook).
24
+ */
25
+ import { getCurrentSessionId } from "../analytics/currentSession";
26
+ import {
27
+ reportClientEventAwait,
28
+ type ClientEvent,
29
+ type ClientEventTarget,
30
+ } from "../analytics/reportClientEvent";
31
+ import { deviceIdStorageKey, mintDeviceId } from "../context/deviceId";
32
+ import { resolveUserContext, type WireUserContext } from "../context/userContext";
33
+ import type { WireOnboardingStorage } from "../session/persistedSession";
34
+ import {
35
+ bumpActivationRevalidation,
36
+ getActivationRevalidationVersion,
37
+ subscribeActivationRevalidation,
38
+ } from "./revalidation";
39
+
40
+ /** Trim a candidate string; return `undefined` for a non-string / blank so callers can `if`-gate. */
41
+ const clean = (value: unknown): string | undefined => {
42
+ if (typeof value !== "string") return undefined;
43
+ const trimmed = value.trim();
44
+ return trimmed.length > 0 ? trimmed : undefined;
45
+ };
46
+
47
+ /**
48
+ * Tenant transport + context inputs for {@link createWireActivation}. `serverUrl`/`apiKey` are the
49
+ * SAME creds as onboarding (never a second key); everything else is optional.
50
+ */
51
+ export type WireActivationConfig = {
52
+ /** Base server URL (same as `WireOnboardingConfig.serverUrl`); `/v1/events` is appended. */
53
+ serverUrl: string;
54
+ /** Tenant API key; sent as `Authorization: Bearer`. */
55
+ apiKey: string;
56
+ /**
57
+ * A stable, non-PII device id → `user_context.device_key` (the server's review/questionnaire
58
+ * gating + A/B stickiness key on it). When omitted, the kit auto-mints ONE per-install id, persists
59
+ * it via `storage` when given, and reuses it — so `device_key` is ALWAYS present. Host-supplied wins.
60
+ */
61
+ deviceKey?: string;
62
+ /**
63
+ * Optional rich context stamped onto every tracked event's `user_context` (opaque `userId` →
64
+ * top-level `user_id`, opt-in `userEmail`, namespaced `extra`). `deviceKey` here is equivalent to
65
+ * the top-level one (top-level wins). Same shape the analytics façade accepts.
66
+ */
67
+ userContext?: WireUserContext;
68
+ /** Tenant/app id — namespaces the auto-minted device-key storage slot. */
69
+ appId?: string;
70
+ /** Host app version → `user_context.app_version` when no explicit `userContext.appVersion` is set. */
71
+ appVersion?: string;
72
+ /** Host persistence (AsyncStorage-compatible subset) so the auto-minted device key survives launches. */
73
+ storage?: WireOnboardingStorage;
74
+ };
75
+
76
+ /** The kit-owned activation surface. `sessionId` is a live getter (reads `getCurrentSessionId()`). */
77
+ export type WireActivation = {
78
+ /**
79
+ * Awaitable action report: POST `event_type='app_event'`, `question_key=<name>`, optional `meta`,
80
+ * under the CURRENT session id + `user_context.device_key`. Resolves `true` once the server has
81
+ * stored it (2xx) and THEN bumps decision revalidation; resolves `false` (no bump) when there is no
82
+ * current session, a blank name, or the POST fails. Never throws.
83
+ */
84
+ track(name: string, meta?: Record<string, unknown>): Promise<boolean>;
85
+ /** The CURRENT per-open session id (the kit's canonical `getCurrentSessionId()`), or `undefined`. */
86
+ readonly sessionId: string | undefined;
87
+ /** Subscribe to decision revalidation (bumped by a successful `track`). Returns an unsubscribe fn. */
88
+ subscribeRevalidation(listener: () => void): () => void;
89
+ /** The current revalidation version — include in a decision-fetch effect's deps to re-fetch on bump. */
90
+ getRevalidationVersion(): number;
91
+ };
92
+
93
+ /**
94
+ * Create a bound activation instance for a tenant transport. Resolves the device key once (explicit >
95
+ * `userContext.deviceKey` > auto-minted + persisted). Pure + React-free.
96
+ */
97
+ export const createWireActivation = (config: WireActivationConfig): WireActivation => {
98
+ const target: ClientEventTarget = { serverUrl: config.serverUrl, apiKey: config.apiKey };
99
+
100
+ // Device key: an explicit id (top-level or in userContext) wins and is never overwritten; otherwise
101
+ // auto-mint ONE and persist via storage (reused every open) so `device_key` is always present.
102
+ const explicitDeviceKey = clean(config.deviceKey) ?? clean(config.userContext?.deviceKey);
103
+ let autoDeviceKey = explicitDeviceKey ?? mintDeviceId();
104
+ if (config.storage && !explicitDeviceKey) {
105
+ const storage = config.storage;
106
+ const key = deviceIdStorageKey(config.appId);
107
+ void storage
108
+ .getItem(key)
109
+ .then((saved) => {
110
+ const persisted = clean(saved ?? undefined);
111
+ if (persisted) autoDeviceKey = persisted;
112
+ else void storage.setItem(key, autoDeviceKey).catch(() => {});
113
+ })
114
+ .catch(() => {});
115
+ }
116
+
117
+ // Stamp the resolved rich context onto the event: `user_context` bucket (device_key always, plus any
118
+ // app_version / opt-in user_email / namespaced extra) and the top-level opaque `user_id`.
119
+ const applyContext = (event: ClientEvent): void => {
120
+ const resolved = resolveUserContext(
121
+ { ...(config.userContext ?? {}), deviceKey: explicitDeviceKey ?? autoDeviceKey },
122
+ { autoAppVersion: config.appVersion },
123
+ );
124
+ if (resolved.userContext) {
125
+ event.user_context = { ...resolved.userContext, ...(event.user_context ?? {}) };
126
+ }
127
+ if (resolved.userId && !event.user_id) event.user_id = resolved.userId;
128
+ };
129
+
130
+ const track = async (name: string, meta?: Record<string, unknown>): Promise<boolean> => {
131
+ const sessionId = getCurrentSessionId();
132
+ // No current session (no app-open registered yet) or a blank name → nothing to correlate; bail
133
+ // WITHOUT bumping (a bump with no posted event would only make the gate re-fetch for nothing).
134
+ if (!clean(name) || !sessionId) return false;
135
+ const event: ClientEvent = {
136
+ event_type: "app_event",
137
+ session_id: sessionId,
138
+ question_key: name,
139
+ };
140
+ if (meta && Object.keys(meta).length > 0) event.meta = JSON.stringify(meta);
141
+ applyContext(event);
142
+ const ok = await reportClientEventAwait(target, event);
143
+ // Only revalidate once the event is actually in the stream — a failed POST leaves the gate as-is.
144
+ if (ok) bumpActivationRevalidation();
145
+ return ok;
146
+ };
147
+
148
+ return {
149
+ track,
150
+ get sessionId() {
151
+ return getCurrentSessionId();
152
+ },
153
+ subscribeRevalidation: subscribeActivationRevalidation,
154
+ getRevalidationVersion: getActivationRevalidationVersion,
155
+ };
156
+ };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * currentSession — a tiny module-level registry of the CURRENT per-open `session_id`.
2
+ * currentSession — a tiny registry of the CURRENT per-open `session_id`.
3
3
  *
4
4
  * WHY it exists (kills the phantom-session): the per-open emitters (`reportSessionStart` and the
5
5
  * `useSessionStart` / `useLifecycleEvents` hooks) mint a fresh `session_id` for each app-open and
@@ -12,24 +12,58 @@
12
12
  * server already ingested. `reportSessionStart` writes the current id here on every open; the façade
13
13
  * reads it so `identify`/app-events correlate to the real session instead of minting a phantom.
14
14
  *
15
- * DEPENDENCY-FREE + PROCESS-LOCAL: a plain module variable. It is intentionally NOT persisted — it
16
- * tracks the CURRENT process's open, and a fresh open always overwrites it. No cross-launch state.
15
+ * ── WHY A globalThis SLOT, NOT A PLAIN MODULE VARIABLE ────────────────────────────────────────
16
+ * This module is exported from TWO package entry points the main `.` bundle (`src/index.ts`) and
17
+ * the `./analytics` subpath (`src/analytics/index.ts`). Under `dist` resolution (node `import`/
18
+ * `require`, which is how tests, SSR and some tooling load the kit) tsup inlines a SEPARATE copy of
19
+ * this module into each bundle, so a plain `let` would give the SETTER (reached via `.` →
20
+ * `reportSessionStart`) and the READER (reached via `./analytics` → façade / `userIdentity`) TWO
21
+ * different variables: the reader would see `undefined` even after an open set the id, and gating
22
+ * would fire under a null session id. On-device this was masked only because Metro's `react-native`
23
+ * export condition resolves both subpaths back to this one `src/` file (a single instance) — a
24
+ * bundler accident, not a guarantee.
25
+ *
26
+ * The bundler-agnostic fix: keep the ONE live value in a well-known `globalThis` slot keyed by a
27
+ * `Symbol.for(...)`. `Symbol.for` uses the runtime-global symbol registry, so every inlined copy of
28
+ * this module resolves the SAME symbol and reads/writes the SAME slot — one identity no matter how
29
+ * many times the module is duplicated across bundles. `globalThis` is present and identical in
30
+ * Hermes/React Native, Node and SSR (we never touch `window`), so this is safe on every host.
31
+ *
32
+ * PROCESS-LOCAL, NOT PERSISTED: the slot lives on the runtime global, so it tracks the CURRENT
33
+ * process's open and a fresh open overwrites it. There is no cross-launch state.
34
+ * `resetCurrentSessionId` clears the slot so a unit test starts from a clean registry.
17
35
  */
18
36
 
19
- let _currentSessionId: string | undefined;
37
+ /**
38
+ * Well-known key into the runtime-global symbol registry. `Symbol.for` (NOT a plain `Symbol()`) is
39
+ * what makes this cross-bundle: it returns the SAME symbol for the same string across every copy of
40
+ * this module, so duplicated inlined copies all address one slot.
41
+ */
42
+ const CURRENT_SESSION_ID_SLOT: unique symbol = Symbol.for(
43
+ "@wireai/activation:currentSessionId",
44
+ );
45
+
46
+ type GlobalWithSlot = typeof globalThis & {
47
+ [CURRENT_SESSION_ID_SLOT]?: string | undefined;
48
+ };
49
+
50
+ const globalSlot = globalThis as GlobalWithSlot;
20
51
 
21
52
  /**
22
53
  * Record the current per-open `session_id`. Called by `reportSessionStart` when it emits an
23
54
  * app-open. A blank / non-string id is ignored (the previous id stays current). Idempotent.
24
55
  */
25
56
  export const setCurrentSessionId = (id: string | undefined): void => {
26
- if (typeof id === "string" && id.length > 0) _currentSessionId = id;
57
+ if (typeof id === "string" && id.length > 0) {
58
+ globalSlot[CURRENT_SESSION_ID_SLOT] = id;
59
+ }
27
60
  };
28
61
 
29
62
  /** The current per-open `session_id`, or `undefined` when no app-open has been registered yet. */
30
- export const getCurrentSessionId = (): string | undefined => _currentSessionId;
63
+ export const getCurrentSessionId = (): string | undefined =>
64
+ globalSlot[CURRENT_SESSION_ID_SLOT];
31
65
 
32
66
  /** Test-only: forget the current session id so a unit test starts from a clean registry. */
33
67
  export const resetCurrentSessionId = (): void => {
34
- _currentSessionId = undefined;
68
+ globalSlot[CURRENT_SESSION_ID_SLOT] = undefined;
35
69
  };
@@ -224,25 +224,10 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
224
224
  }
225
225
  })();
226
226
 
227
- // The outcome of one POST attempt:
228
- // "ok" → the server accepted the batch (2xx) dequeue it.
229
- // "drop" → a BATCH-SPECIFIC permanent error (this batch will never be accepted no matter how
230
- // often we retry) discard it and continue, so it can't block the good events behind
231
- // it (head-of-line). See DROP_STATUSES.
232
- // "retry" → transient or tenant-wide (network/5xx/429/auth) → keep the batch and back off.
233
- type PostResult = "ok" | "drop" | "retry";
234
-
235
- // Statuses where retrying THIS batch is futile because the batch itself is the problem — a
236
- // malformed body (400/422), a too-large body (413), or a wrong route (404). Dropping a poison
237
- // batch is what stops it from stalling the whole backlog. Deliberately NOT here: 401/403 (auth is
238
- // tenant-wide, not batch-specific — dropping would silently lose EVERY event on a recoverable
239
- // credential blip, so we keep retrying/pausing instead) and 429/5xx (transient).
240
- const DROP_STATUSES = new Set([400, 404, 413, 422]);
241
-
242
- // The queue's OWN awaited POST. Classifies the response to drive dequeue/drop/retry. NEVER throws —
243
- // a missing fetch, a rejecting network, or an abort resolves to "retry" (batch stays, retry schedules).
244
- const postBatch = async (events: ClientEvent[]): Promise<PostResult> => {
245
- if (!target?.serverUrl || events.length === 0) return "retry";
227
+ // The queue's OWN awaited POST. Reads `res.ok` to drive retry/dequeue. NEVER throws — a missing
228
+ // fetch, a rejecting network, or a JSON error resolves to `false` (batch stays, retry schedules).
229
+ const postBatch = async (events: ClientEvent[]): Promise<boolean> => {
230
+ if (!target?.serverUrl || events.length === 0) return false;
246
231
  const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
247
232
  const timer = setTimeout(() => controller?.abort(), 15_000);
248
233
  try {
@@ -255,12 +240,9 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
255
240
  body: JSON.stringify({ events }),
256
241
  signal: controller?.signal,
257
242
  });
258
- if (res && (res as { ok?: boolean }).ok) return "ok";
259
- const status = (res as { status?: number } | undefined)?.status;
260
- if (typeof status === "number" && DROP_STATUSES.has(status)) return "drop";
261
- return "retry";
243
+ return !!(res && (res as { ok?: boolean }).ok);
262
244
  } catch {
263
- return "retry";
245
+ return false;
264
246
  } finally {
265
247
  clearTimeout(timer);
266
248
  }
@@ -298,13 +280,12 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
298
280
  try {
299
281
  while (pending.length > 0) {
300
282
  const batch = pending.slice(0, batchSize);
301
- const result = await postBatch(batch.map((item) => item.event));
302
- if (result === "retry") {
283
+ const ok = await postBatch(batch.map((item) => item.event));
284
+ if (!ok) {
303
285
  scheduleRetry();
304
286
  return;
305
287
  }
306
- // "ok" (accepted) or "drop" (poison batch discarded so it can't block the rest): remove
307
- // exactly this batch by id (pending may have grown while in flight) and keep draining.
288
+ // Dequeue exactly the acked batch by id (pending may have grown while in flight).
308
289
  const acked = new Set(batch.map((item) => item.id));
309
290
  pending = pending.filter((item) => !acked.has(item.id));
310
291
  persist();
@@ -31,7 +31,13 @@ export { reportAppEvent } from "../reviews/transport";
31
31
  export type { ReportAppEventOptions } from "../reviews/transport";
32
32
 
33
33
  // ─── Device-only onboarding event reporters + session id seed ─────────────────
34
- export { reportClientEvent, reportClientEvents, makeSessionId } from "./reportClientEvent";
34
+ export {
35
+ reportClientEvent,
36
+ reportClientEvents,
37
+ reportClientEventAwait,
38
+ reportClientEventsAwait,
39
+ makeSessionId,
40
+ } from "./reportClientEvent";
35
41
  export type { ClientEvent, ClientEventType, ClientEventTarget } from "./reportClientEvent";
36
42
 
37
43
  // ─── Canonical onboarding funnel names + kit-event mapper ──────────────────────
@@ -95,6 +95,29 @@ export type ClientEventTarget = {
95
95
  export const makeSessionId = (): string =>
96
96
  `wire_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
97
97
 
98
+ /**
99
+ * The ONE place the `/v1/events` POST is described (url + method + headers + body). Both the
100
+ * fire-and-forget {@link reportClientEvents} and the awaitable {@link reportClientEventsAwait}
101
+ * build their request here so there is a SINGLE definition of the events transport — no second
102
+ * copy of the endpoint path, headers, or envelope shape to drift. Returns `null` when there is
103
+ * nothing to send (no target / no events) or serialization throws, so callers just bail.
104
+ */
105
+ const buildEventsRequest = (
106
+ target: ClientEventTarget | undefined,
107
+ events: ClientEvent[],
108
+ ): { url: string; init: RequestInit } | null => {
109
+ if (!target?.serverUrl || events.length === 0) return null;
110
+ try {
111
+ const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
112
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
113
+ if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
114
+ return { url, init: { method: "POST", headers, body: JSON.stringify({ events }) } };
115
+ } catch {
116
+ // URL construction or JSON serialization failed — nothing to send.
117
+ return null;
118
+ }
119
+ };
120
+
98
121
  /**
99
122
  * POST one or more client events, fire-and-forget. A missing/invalid target, a build error,
100
123
  * a missing `fetch`, or a network failure is swallowed — the call returns immediately and the
@@ -104,20 +127,14 @@ export const reportClientEvents = (
104
127
  target: ClientEventTarget | undefined,
105
128
  events: ClientEvent[],
106
129
  ): void => {
107
- if (!target?.serverUrl || events.length === 0) return;
108
130
  try {
109
- const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
110
- const headers: Record<string, string> = { "Content-Type": "application/json" };
111
- if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
112
- void fetch(url, {
113
- method: "POST",
114
- headers,
115
- body: JSON.stringify({ events }),
116
- }).catch(() => {
131
+ const req = buildEventsRequest(target, events);
132
+ if (!req) return;
133
+ void fetch(req.url, req.init).catch(() => {
117
134
  // Network/transport error — analytics is best-effort, swallow.
118
135
  });
119
136
  } catch {
120
- // URL construction, JSON serialization, or a missing fetch — swallow.
137
+ // A missing `fetch` — swallow.
121
138
  }
122
139
  };
123
140
 
@@ -126,3 +143,32 @@ export const reportClientEvent = (
126
143
  target: ClientEventTarget | undefined,
127
144
  event: ClientEvent,
128
145
  ): void => reportClientEvents(target, [event]);
146
+
147
+ /**
148
+ * AWAITABLE sibling of {@link reportClientEvents}: POST one or more client events through the SAME
149
+ * `/v1/events` path, but resolve only once the server has RESPONDED — so a decision re-fetch fired
150
+ * immediately after is guaranteed to see the event in the session stream (this is the guarantee
151
+ * `wire.track` needs before it triggers decision revalidation). Never throws: a missing/invalid
152
+ * target, a missing `fetch`, a network error, or a non-2xx status all resolve to `false`. Resolves
153
+ * `true` only on a 2xx response.
154
+ */
155
+ export const reportClientEventsAwait = async (
156
+ target: ClientEventTarget | undefined,
157
+ events: ClientEvent[],
158
+ ): Promise<boolean> => {
159
+ try {
160
+ const req = buildEventsRequest(target, events);
161
+ if (!req) return false;
162
+ const res = await fetch(req.url, req.init);
163
+ return Boolean(res && res.ok);
164
+ } catch {
165
+ // Unreachable / missing-fetch / network — best-effort, report failure.
166
+ return false;
167
+ }
168
+ };
169
+
170
+ /** Convenience single-event wrapper around {@link reportClientEventsAwait}. */
171
+ export const reportClientEventAwait = (
172
+ target: ClientEventTarget | undefined,
173
+ event: ClientEvent,
174
+ ): Promise<boolean> => reportClientEventsAwait(target, [event]);
package/src/index.ts CHANGED
@@ -167,6 +167,24 @@ export {
167
167
  resetCurrentSessionId,
168
168
  } from "./analytics/currentSession";
169
169
 
170
+ // ─── Awaitable client-event report (the transport `wire.track` posts through) ─
171
+ export { reportClientEventAwait, reportClientEventsAwait } from "./analytics/reportClientEvent";
172
+
173
+ // ─── Activation consolidation: wire.track() + useWireActivation() + revalidation ──
174
+ export { createWireActivation, useWireActivation } from "./activation";
175
+ export type {
176
+ WireActivation,
177
+ WireActivationConfig,
178
+ UseWireActivation,
179
+ } from "./activation";
180
+ export {
181
+ useActivationRevalidation,
182
+ bumpActivationRevalidation,
183
+ subscribeActivationRevalidation,
184
+ getActivationRevalidationVersion,
185
+ resetActivationRevalidation,
186
+ } from "./activation";
187
+
170
188
  // ─── Session mapping (one `app.session_started` per app-open → /v1/events) ─────
171
189
  export {
172
190
  reportSessionStart,
@@ -75,15 +75,7 @@ export const fetchQuestionnaireDecision = async (
75
75
  const res = await fetch(url, { headers });
76
76
  if (!res || !res.ok) return null;
77
77
  const json = (await res.json()) as QuestionnaireDecisionResponse | null;
78
- // A body without a boolean `fire` is not a decision — guard it exactly as the mirror
79
- // `fetchReviewDecision` does. Without this, a malformed 2xx body flows straight through:
80
- // `{}` reads as a truthy verdict whose `fire` is undefined (silently "never fire", killing
81
- // a questionnaire the local rules would have shown), and a stringy `{fire:"yes"}` reads as
82
- // FIRE while carrying no `questionnaire`, so the host renders a gate with an undefined
83
- // definition. Only a genuine boolean-`fire` body is a decision; everything else → null (the
84
- // server has no opinion → the local rules stand).
85
- if (!json || typeof json.fire !== "boolean") return null;
86
- return json;
78
+ return json ?? null;
87
79
  } catch {
88
80
  /* unreachable / non-2xx / bad JSON / missing-fetch - never show */
89
81
  return null;