@wireai/activation 0.9.1 → 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.1",
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>",
@@ -27,6 +27,7 @@ import {
27
27
  clearPersistedSession,
28
28
  loadPersistedSession,
29
29
  sessionStorageKey,
30
+ shouldClearOnComplete,
30
31
  DEFAULT_SESSION_TTL_MS,
31
32
  type LoadedSession,
32
33
  } from "./session/persistedSession";
@@ -52,6 +53,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
52
53
  storage,
53
54
  sessionTtlMs = DEFAULT_SESSION_TTL_MS,
54
55
  persistKey,
56
+ retainSessionOnComplete,
55
57
  userContext,
56
58
  userId,
57
59
  }) => {
@@ -123,15 +125,24 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
123
125
  };
124
126
  }, [storage, storageKey, sessionTtlMs, session]);
125
127
 
126
- // Clear the cached id the moment the flow completes, so the next onboarding on this
127
- // device starts a fresh session. Dropped/degraded paths deliberately do NOT clear —
128
+ // By DEFAULT, clear the cached id the moment the flow completes, so the next onboarding on
129
+ // this device starts a fresh session. Dropped/degraded paths deliberately do NOT clear —
128
130
  // a dropped session must stay resumable within the TTL.
131
+ //
132
+ // OPT-IN (`retainSessionOnComplete`): a multi-stage / replay-after-complete signup re-enters
133
+ // (often via a `key=` remount) WITHIN one signup. If we cleared, that re-entry would find no
134
+ // seed and mint a fresh `metadata.sessionId` → a phantom second `session_started` on the same
135
+ // funnel. When the host opts in, we LEAVE the seed in place so the re-entry resumes the SAME
136
+ // session (one funnel start); freshness for a genuinely new run then rides the TTL (past
137
+ // `sessionTtlMs` → fresh mint) and an explicit new-run signal (a changed `persistKey`).
138
+ // `shouldClearOnComplete` is the pure, unit-tested seam; the `storage` guard is unchanged, so a
139
+ // host that does not opt in lands on the byte-for-byte legacy clear-on-complete path.
129
140
  const handleComplete = useCallback(
130
141
  (result: OnboardingResult) => {
131
- if (storage) clearPersistedSession(storage, storageKey);
142
+ if (storage && shouldClearOnComplete(retainSessionOnComplete)) clearPersistedSession(storage, storageKey);
132
143
  onComplete(result);
133
144
  },
134
- [storage, storageKey, onComplete],
145
+ [storage, storageKey, retainSessionOnComplete, onComplete],
135
146
  );
136
147
 
137
148
  const sessionId = session?.id ?? "";
@@ -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
  };
@@ -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,
@@ -141,3 +141,20 @@ export const clearPersistedSession = (storage: WireOnboardingStorage, key: strin
141
141
  // Best-effort.
142
142
  }
143
143
  };
144
+
145
+ /**
146
+ * The opt-in decision for `WireOnboarding.handleComplete`: given the host's
147
+ * `retainSessionOnComplete` opt-in, whether a completion should CLEAR the persisted seed.
148
+ * (The caller still gates on `storage` being present — this only encodes the opt-in axis, so the
149
+ * legacy `if (storage) clearPersistedSession(...)` guard stays byte-for-byte for a host that does
150
+ * not opt in.)
151
+ *
152
+ * - undefined / false (the default) → `true`: clear on completion, so the NEXT onboarding on this
153
+ * device mints a fresh session. This is the legacy single-stage behavior (e.g. Myelino).
154
+ * - true → `false`: LEAVE the seed in place so a same-signup re-entry/remount within the TTL
155
+ * resumes the SAME session (one funnel start) instead of minting a phantom second `started`.
156
+ * Freshness for a genuinely new run is then governed by the TTL (past `sessionTtlMs` → fresh
157
+ * mint) and an explicit new-run signal (a changed `persistKey`).
158
+ */
159
+ export const shouldClearOnComplete = (retainSessionOnComplete: boolean | undefined): boolean =>
160
+ !retainSessionOnComplete;
package/src/types.ts CHANGED
@@ -211,6 +211,22 @@ export type WireOnboardingProps = {
211
211
  * accounts mid-flow. Only meaningful with `storage`.
212
212
  */
213
213
  persistKey?: string;
214
+ /**
215
+ * OPT-IN: keep the persisted session seed alive ACROSS completion, so a multi-stage or
216
+ * replay-after-complete re-entry WITHIN one signup (typically a `key=` remount) resumes the
217
+ * SAME session instead of minting a fresh `metadata.sessionId` — which the backend would adopt
218
+ * as a second `contextId`, double-counting a `session_started` on the funnel.
219
+ *
220
+ * Default (omitted / `false`): a completion CLEARS the seed, so the next onboarding on this
221
+ * device starts fresh — the legacy single-stage behavior. Leave it unset and NOTHING changes.
222
+ *
223
+ * When `true`: the seed survives completion, and freshness for a genuinely new run is governed
224
+ * by the TTL (past `sessionTtlMs` → a fresh seed is minted) and an explicit new-run signal
225
+ * (scope a new `persistKey`, e.g. a per-signup id, to force a fresh seed within the TTL).
226
+ *
227
+ * Only meaningful with `storage`. Dropped/degraded sessions are unaffected (they never clear).
228
+ */
229
+ retainSessionOnComplete?: boolean;
214
230
  };
215
231
 
216
232
  /** Backend-supplied progress, read off `response.props.progress` when present. */