@wireai/activation 0.13.6 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/AGENTS.md +21 -9
  2. package/CHANGELOG.md +172 -1
  3. package/INTEGRATION_PROMPT.md +7 -4
  4. package/README.md +10 -2
  5. package/dist/analytics/index.d.mts +9 -2
  6. package/dist/analytics/index.d.ts +9 -2
  7. package/dist/analytics/index.js +56 -12
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +56 -12
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/{currentSession-DngW-QoD.d.mts → currentSession-CUvTOchb.d.mts} +35 -6
  12. package/dist/{currentSession-C5976akx.d.ts → currentSession-CW_5Mq4O.d.ts} +35 -6
  13. package/dist/index.d.mts +24 -6
  14. package/dist/index.d.ts +24 -6
  15. package/dist/index.js +631 -548
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +631 -548
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/questionnaire/index.js.map +1 -1
  20. package/dist/questionnaire/index.mjs.map +1 -1
  21. package/dist/reviews/index.js.map +1 -1
  22. package/dist/reviews/index.mjs.map +1 -1
  23. package/llms.txt +2 -2
  24. package/package.json +1 -1
  25. package/src/OnboardingFlow.tsx +4 -3
  26. package/src/WireOnboarding.tsx +4 -3
  27. package/src/activation/useWireActivation.ts +14 -1
  28. package/src/activation/wireActivation.ts +68 -2
  29. package/src/analytics/analyticsFacade.ts +24 -0
  30. package/src/analytics/eventQueue.ts +106 -11
  31. package/src/analytics/reportClientEvent.ts +31 -7
  32. package/src/analytics/useAnalytics.ts +17 -0
  33. package/src/context/deviceId.ts +10 -3
  34. package/src/permissions/permissionMemory.ts +12 -1
  35. package/src/session/persistedSession.ts +32 -8
  36. package/src/session-analytics/lifecycle.ts +26 -5
  37. package/src/session-analytics/reportSessionStart.ts +14 -10
  38. package/src/session-analytics/useLifecycleEvents.ts +70 -32
  39. package/src/session-analytics/useSessionStart.ts +57 -15
  40. package/src/types.ts +16 -6
  41. package/src/utils/readPlan.ts +8 -5
package/llms.txt CHANGED
@@ -12,8 +12,8 @@
12
12
  - **Metro (required):** `module.exports = withWireOnboarding(getDefaultConfig(__dirname))` from `@wireai/activation/metro`, which pins one copy of react/react-native/wireai-rn/zod (prevents the dual-React crash).
13
13
  - **Two secrets:** an app `apiKey` (a `wai_…` key that resolves the tenant server-side) and the backend `serverUrl`. Created in the getwireai console or via the backend's `register_<app>.py`. Nothing renders without both.
14
14
  - **Render:** drop `<WireOnboarding config={wireConfigFromEnv({ appId })} theme={...} onComplete={persist} fallbackFlow={<YourStaticOnboarding/>} />` into the signup flow.
15
- - **The join key.** Pass `userContext={activationJoinContext(deviceKey)}` on `<WireOnboarding>` (or `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))` when the app owns no device id). `user_context.device_key` is the ONLY thing that joins an onboarding session to the app's later events; omit it and the `activated` funnel reads zero with no error.
16
- - **Lifecycle (mount it once):** `useLifecycleEvents(config, { deviceKey?, sessionCount?, userId? })` at the app root, before anything else touches analytics. It is the only path that emits `app.first_open`, it registers the per-open session id `createAnalytics` / `wire.track` / the gates all correlate to, and with `config.storage` it stamps the persisted auto `device_key` on `app.session_started`, which is what the server counts `min_sessions` from. `useSessionStart` / `reportSessionStart` are the alternatives for a host that already owns an open counter; neither emits `first_open`.
15
+ - **The join key.** Pass `userContext={activationJoinContext(deviceKey)}` on `<WireOnboarding>`, or, when the app owns no device id, pass a working `storage` and leave `userContext` alone so the kit injects its own (⛔ never hand-build it from the synchronous `resolveAutoDeviceKey`, which cannot report whether the id survives the launch). `user_context.device_key` is the ONLY thing that joins an onboarding session to the app's later events; omit it and the `activated` funnel reads zero with no error.
16
+ - **Lifecycle (mount it once):** `useLifecycleEvents(config, { deviceKey?, sessionCount?, userId? })` at the app root, before anything else touches analytics. It is the only path that emits `app.first_open`, it registers the per-open session id `createAnalytics` / `wire.track` / the gates all correlate to, and with `config.storage` it stamps the persisted auto `device_key` on `app.session_started`, which is what the server counts `min_sessions` from. Storage that throws or rejects counts as no storage since 0.14.0: the events fire with no auto key rather than a per-launch one. `useSessionStart` / `reportSessionStart` are the alternatives for a host that already owns an open counter; neither emits `first_open`, and `useSessionStart` follows the same auto-key rule since 0.14.0.
17
17
  - **Capture is deterministic.** `onComplete` returns an `OnboardingResult`: `answers` and `raw` always, plus (since 0.13.6) `plan` when the backend sent an onboarding plan and `variant` when the tenant runs an experiment and the backend assigned an arm. No second LLM call. Persist all answers through the app's normal profile-update path. Those two extra keys are set ONLY when the backend actually sent them, so a run without them returns exactly the object earlier versions returned, and the kit interprets neither: validate `plan` before applying it.
18
18
  - **It can never break onboarding.** Pass `fallbackFlow` (your existing static onboarding) so a backend error/timeout degrades instead of dead-ending.
19
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.13.6",
3
+ "version": "0.14.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>",
@@ -381,9 +381,10 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
381
381
  if (finished.current) return;
382
382
  finished.current = true;
383
383
  const result: OnboardingResult = { answers: deriveAnswers(messages), raw: messages };
384
- // The plan rides a second DataPart on the AI path only; the static flow carries none. Setting
385
- // the key ONLY when there is one keeps the no-plan result byte-identical to the pre-plan
386
- // object — a host that inspects `Object.keys(result)` or `"plan" in result` sees no change.
384
+ // The plan rides a second DataPart and is present when the backend sent one the kit never
385
+ // infers it from which flow ran. Setting the key ONLY when there is one keeps the no-plan
386
+ // result byte-identical to the pre-plan object — a host that inspects `Object.keys(result)` or
387
+ // `"plan" in result` sees no change.
387
388
  const plan = readPlan(messages);
388
389
  if (plan !== undefined) result.plan = plan;
389
390
  // The experiment arm, latched off the render envelopes during the flow. Set under exactly the
@@ -254,9 +254,10 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
254
254
  warnInDev(
255
255
  "[wireai] <WireOnboarding> got no user_context.device_key, so this onboarding session can " +
256
256
  "never be joined to the app's later events and the `activated` funnel will read zero. Pass " +
257
- "userContext={activationJoinContext(deviceKey)} — and if your app owns no device id, " +
258
- "userContext={activationJoinContext(resolveAutoDeviceKey({ appId, storage }))} returns the " +
259
- "SAME id the analytics side stamps. Never hand-write userContext={{ deviceKey }}. " +
257
+ "userContext={activationJoinContext(deviceKey)} if your app owns a device id. If it does " +
258
+ "NOT, do not build one here — leave userContext alone and pass a working `storage` instead: " +
259
+ "the kit injects the same id the analytics side stamps, and only when it has confirmed the " +
260
+ "id actually persists. Never hand-write userContext={{ deviceKey }}. " +
260
261
  "The kit did NOT auto-inject its own key here because " +
261
262
  autoJoinReason,
262
263
  );
@@ -16,7 +16,7 @@
16
16
  * `useScreenTracking`) split: the factory stays React-free; this is the glue. The instance is built
17
17
  * once per mount and held in a ref, so re-renders never rebuild it or re-resolve the device key.
18
18
  */
19
- import { useRef, useSyncExternalStore } from "react";
19
+ import { useEffect, useRef, useSyncExternalStore } from "react";
20
20
 
21
21
  import {
22
22
  getActivationRevalidationVersion,
@@ -73,9 +73,22 @@ export const useWireActivation = (config: WireActivationConfig): UseWireActivati
73
73
  ].join("|");
74
74
  if (!ref.current || prevKeys.current !== currentKeys) {
75
75
  prevKeys.current = currentKeys;
76
+ // Hand back the instance being replaced: a `track()` that failed while offline may have opened a
77
+ // durability queue, which owns a storage claim and a backoff timer. See `WireActivation.dispose`.
78
+ ref.current?.dispose();
76
79
  ref.current = createWireActivation(config);
77
80
  }
78
81
 
82
+ // UNMOUNT: same reason. A per-screen instance that buffered a failed action must not leave its
83
+ // queue holding the slot the next instance needs.
84
+ useEffect(
85
+ () => () => {
86
+ ref.current?.dispose();
87
+ ref.current = undefined;
88
+ },
89
+ [],
90
+ );
91
+
79
92
  const revalidation = useActivationRevalidation();
80
93
  return { track: ref.current.track, sessionId: ref.current.sessionId, revalidation };
81
94
  };
@@ -24,10 +24,11 @@
24
24
  */
25
25
  import { ensureCurrentSessionId, getCurrentSessionId } from "../analytics/currentSession";
26
26
  import {
27
- reportClientEventAwait,
27
+ reportClientEventsOutcome,
28
28
  type ClientEvent,
29
29
  type ClientEventTarget,
30
30
  } from "../analytics/reportClientEvent";
31
+ import { createEventQueue, type EventQueue } from "../analytics/eventQueue";
31
32
  import { resolveAutoDeviceKey, type ResolveAutoDeviceKeyOptions } from "../context/deviceId";
32
33
  import { cleanString, resolveUserContext, type WireUserContext } from "../context/userContext";
33
34
  import { resolveIdentity } from "../identity/identityRecord";
@@ -69,6 +70,15 @@ export type WireActivationConfig = {
69
70
  appVersion?: string;
70
71
  /** Host persistence (AsyncStorage-compatible subset) so the auto-minted device key survives launches. */
71
72
  storage?: WireOnboardingStorage;
73
+ /**
74
+ * Where a FAILED `track()` goes instead of being dropped: your existing offline queue's `enqueue`
75
+ * (the same wiring `useLifecycleEvents` takes). Pass it when you already run one queue for the app
76
+ * and you want one backlog, one retry schedule, one storage slot.
77
+ *
78
+ * Omit it and the instance opens its OWN queue the first time a POST fails — never before, so an
79
+ * app that is online the whole time allocates nothing. A successful `track()` never touches either.
80
+ */
81
+ sink?: (event: ClientEvent) => void;
72
82
  };
73
83
 
74
84
  /** The kit-owned activation surface. `sessionId` is a live getter (reads `getCurrentSessionId()`). */
@@ -92,6 +102,12 @@ export type WireActivation = {
92
102
  subscribeRevalidation(listener: () => void): () => void;
93
103
  /** The current revalidation version — include in a decision-fetch effect's deps to re-fetch on bump. */
94
104
  getRevalidationVersion(): number;
105
+ /**
106
+ * Tear down the durability queue a failed `track()` may have opened (see
107
+ * {@link EventQueue.dispose}). Idempotent, never throws, and a no-op on an instance that never had
108
+ * a failure. Call it when you build an instance per mount; `useWireActivation` does it for you.
109
+ */
110
+ dispose(): void;
95
111
  };
96
112
 
97
113
  /**
@@ -147,6 +163,36 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
147
163
  if (resolved.userId && !event.user_id) event.user_id = resolved.userId;
148
164
  };
149
165
 
166
+ /**
167
+ * The durability path for a FAILED `track()`, built on the first failure and never before.
168
+ *
169
+ * WHY LAZY: an app that is online the whole time should allocate no queue, claim no storage slot
170
+ * and arm no timer. The queue exists to catch the events a bare POST used to destroy.
171
+ *
172
+ * WHY AN EXPLICIT KEY: it is a slot this instance OWNS, distinct from the façade's
173
+ * `wireai:evtq:<appId>`, and an explicit key is rotation-exempt — a remount's replacement instance
174
+ * addresses the same backlog instead of a `…#2` nobody drains. `dispose()` hands it back.
175
+ *
176
+ * AT-LEAST-ONCE, deliberately: a POST whose response was lost (an abort, a 15s timeout) may have
177
+ * reached the server, so a retry can duplicate it. That is the trade this kit already makes on
178
+ * every other event family, and the direction to fail in for a trigger that must not undercount.
179
+ */
180
+ let durability: EventQueue | undefined;
181
+ const bufferFailedEvent = (event: ClientEvent): void => {
182
+ if (config.sink) {
183
+ config.sink(event);
184
+ return;
185
+ }
186
+ if (!durability) {
187
+ durability = createEventQueue({
188
+ target,
189
+ storage: config.storage,
190
+ storageKey: `wireai:evtq:activation:${config.appId ?? "default"}`,
191
+ });
192
+ }
193
+ durability.enqueue(event);
194
+ };
195
+
150
196
  const track = async (name: string, meta?: Record<string, unknown>): Promise<boolean> => {
151
197
  // A blank name is the only thing left to refuse on: there is no `question_key` to match a
152
198
  // firing trigger against, so bail WITHOUT bumping (a bump with no posted event would only make
@@ -164,9 +210,25 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
164
210
  };
165
211
  if (meta && Object.keys(meta).length > 0) event.meta = JSON.stringify(meta);
166
212
  applyContext(event);
167
- const ok = await reportClientEventAwait(target, event);
213
+ const outcome = await reportClientEventsOutcome(target, [event]);
214
+ const ok = outcome === "delivered";
168
215
  // Only revalidate once the event is actually in the stream — a failed POST leaves the gate as-is.
169
216
  if (ok) bumpActivationRevalidation();
217
+ // A failed POST used to end the event's life right here. `track` is the ACTION reporter, and it
218
+ // is called at exactly the moments a phone is offline (a purchase on a train, a trial in a lift),
219
+ // so the server-side trigger keyed on that `question_key` simply never fired for that user. The
220
+ // event now goes to a durable buffer instead, and rides the same retry + persistence every other
221
+ // event family in this kit already gets.
222
+ //
223
+ // The RETURN VALUE stays `false`, deliberately: the caller asked whether the server has it NOW,
224
+ // and it does not. Revalidation stays conservative for the same reason — a deferred delivery does
225
+ // not bump it, so a gate re-fetch is never triggered by an event the server may still reject.
226
+ //
227
+ // ⛔ ONLY `unreachable`. A `refused` event is one the server READ and declined (a 200 carrying
228
+ // `skipped`), so re-sending it is a re-decline: it would sit in the PERSISTED backlog and be
229
+ // rejected again on every drain and every launch, forever. Durability is for the events the
230
+ // network lost, never for the ones the server rejected.
231
+ if (outcome === "unreachable") bufferFailedEvent(event);
170
232
  return ok;
171
233
  };
172
234
 
@@ -177,5 +239,9 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
177
239
  },
178
240
  subscribeRevalidation: subscribeActivationRevalidation,
179
241
  getRevalidationVersion: getActivationRevalidationVersion,
242
+ dispose: () => {
243
+ durability?.dispose();
244
+ durability = undefined;
245
+ },
180
246
  };
181
247
  };
@@ -140,6 +140,13 @@ export type Analytics = {
140
140
  notifyOnline(): void;
141
141
  /** Current pending (in-memory) count. */
142
142
  size(): number;
143
+ /**
144
+ * Tear the instance's event queue down when its owner goes away (see {@link EventQueue.dispose}).
145
+ * Idempotent, never throws. Call it if you build an instance per screen / per mount: the queue
146
+ * claims a persisted storage slot, and a replacement built before the old one released it lands on
147
+ * a rotated `…#2` key that no later launch ever reads. `useAnalytics` does this for you.
148
+ */
149
+ dispose(): void;
143
150
  };
144
151
 
145
152
  /**
@@ -245,10 +252,22 @@ export const createAnalytics = (
245
252
  let boundUserId: string | undefined = sanitizeUserId(config.userContext?.userId);
246
253
  const storageKey = analyticsUserIdStorageKey(config.appId);
247
254
 
255
+ // SUPERSESSION LATCH for the construction-time hydration below.
256
+ //
257
+ // The read's guard used to be `saved && !boundUserId` alone, which asks "did something bind an id
258
+ // while I was reading?". A LOGOUT is the one case where the answer is a deliberate `undefined`, so
259
+ // a read that landed after `reset()` sailed through the guard and re-bound the user who had just
260
+ // logged out — onto every subsequent event, which is precisely what `reset()` promises cannot
261
+ // happen. The window is the first storage read of a cold start, and a logout inside it is a real
262
+ // sequence, not a contrived one. An empty binding must be able to mean "cleared on purpose".
263
+ let hydrationSuperseded = false;
264
+
248
265
  if (config.storage) {
249
266
  void config.storage
250
267
  .getItem(storageKey)
251
268
  .then((saved) => {
269
+ // A `reset()` that already ran wins, whatever this read says.
270
+ if (hydrationSuperseded) return;
252
271
  // Don't clobber an explicit init-context user id with a stale persisted one.
253
272
  if (saved && !boundUserId) boundUserId = saved;
254
273
  })
@@ -311,6 +330,10 @@ export const createAnalytics = (
311
330
  };
312
331
 
313
332
  const reset = (): void => {
333
+ // Supersede any construction-time hydration still in flight, BEFORE clearing the binding: the
334
+ // read cannot tell "nobody bound anything yet" from "the user just logged out", so the answer
335
+ // has to come from here. Without this the read re-binds the logged-out user.
336
+ hydrationSuperseded = true;
314
337
  // In-memory binding cleared: subsequent events carry no user_id until the next identify.
315
338
  boundUserId = undefined;
316
339
  // Strip PII from the rich context but keep the device-scope fields (device_key / app_version).
@@ -377,5 +400,6 @@ export const createAnalytics = (
377
400
  flush: queue.flush,
378
401
  notifyOnline: queue.notifyOnline,
379
402
  size: queue.size,
403
+ dispose: queue.dispose,
380
404
  };
381
405
  };
@@ -73,6 +73,19 @@ export type EventQueue = {
73
73
  notifyOnline(): void;
74
74
  /** Current pending (in-memory) count. */
75
75
  size(): number;
76
+ /**
77
+ * Tear this queue down when its owner goes away (a hook's effect cleanup, a host disposing its
78
+ * own instance). Idempotent, never throws, and REQUIRED for any queue that can be re-created:
79
+ * a remount builds a second queue while the first is still holding the storage claim and a live
80
+ * backoff timer, and the two then fight over one persisted slot.
81
+ *
82
+ * It clears the retry timer, releases the claimed storage key so a replacement gets the SAME slot
83
+ * instead of a rotated `…#2` nobody reads next launch, and makes the queue inert — `enqueue`,
84
+ * `flush` and every write become no-ops, so a timer that already fired cannot `removeItem` the
85
+ * slot the live queue owns. Anything still buffered in memory at that point stays on disk under
86
+ * the released key, which is exactly where the replacement queue looks for it.
87
+ */
88
+ dispose(): void;
76
89
  };
77
90
 
78
91
  const DEFAULTS = {
@@ -146,6 +159,21 @@ const claimQueueKey = (preferred: string, explicit: boolean): string => {
146
159
  return key;
147
160
  };
148
161
 
162
+ /**
163
+ * Give a claimed key back, so the NEXT queue for the same appId gets the real slot instead of a
164
+ * rotated one. Called only from {@link EventQueue.dispose}.
165
+ *
166
+ * WHY THIS HAS TO EXIST: the claim was write-only, and "already claimed" was read as "held by a LIVE
167
+ * one" — liveness nothing ever tracked. A remount (nav, Fast Refresh, StrictMode's double-invoke, an
168
+ * appId that arrives async) therefore rotated the replacement onto `…#2`, and from then on the whole
169
+ * launch persisted into a slot the next launch never reads: it claims the base key, finds the older
170
+ * blob, and the `#N` ones accumulate forever. Releasing on teardown makes the claim mean what its
171
+ * warning already claimed it meant.
172
+ */
173
+ const releaseQueueKey = (key: string): void => {
174
+ claimedQueueKeys().delete(key);
175
+ };
176
+
149
177
  /** Test-only: forget every claimed queue key. A real RELAUNCH is a new process, so a test that
150
178
  * simulates one in-process must call this or its second queue reads as a concurrent sibling.
151
179
  * Exported from `@wireai/activation/analytics`, matching `resetAutoDeviceKeys` / `resetCurrentSessionId`. */
@@ -231,13 +259,23 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
231
259
  let flushing = false;
232
260
  let attempt = 0;
233
261
  let retryTimer: ReturnType<typeof setTimeout> | undefined;
234
- // The persisted blob exists but has NOT been read yet (the cold-start read blew its deadline and
235
- // is still in flight). While this is true every write is suppressed: the only thing the queue
236
- // could write is a view of `pending` that does not include the backlog it has not seen, and
237
- // `setItem`/`removeItem` would destroy it. Cleared as soon as the slow read settles, whichever
238
- // way it settles, so a read that ultimately fails resumes normal persistence rather than
239
- // suppressing it for the life of the process.
240
- let backlogUnread = false;
262
+ // Set by `dispose()`. Every write path and the drain check it, because a queue whose owner is
263
+ // gone must not touch a storage slot a replacement queue now owns.
264
+ let disposed = false;
265
+ // The persisted blob may exist and has NOT been read yet. While this is true every write is
266
+ // suppressed: the only thing the queue could write is a view of `pending` that does not include
267
+ // the backlog it has not seen, and `setItem`/`removeItem` would destroy it. Cleared as soon as the
268
+ // read settles, whichever way it settles, so a read that ultimately fails resumes normal
269
+ // persistence rather than suppressing it for the life of the process.
270
+ //
271
+ // ARMED AT CONSTRUCTION, not when the 1500ms timeout fires. It used to be set ONLY in the timeout
272
+ // branch, so for the first 1500ms of every launch the comment above was false and the queue wrote
273
+ // freely over a blob it had not read. The sharp form is not the overwrite but the DELETE: a queue
274
+ // that drains to empty inside that window calls `removeItem` on the slot, and an adapter that
275
+ // resolves a read against its current state (a bridge that queues operations and runs them in
276
+ // order — not every adapter snapshots at call time) then answers `null`. The whole backlog is
277
+ // gone, with no error anywhere.
278
+ let backlogUnread = storage !== undefined;
241
279
 
242
280
  const resolveEnvelope = (): ContextEnvelope | undefined => {
243
281
  try {
@@ -275,6 +313,8 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
275
313
 
276
314
  const persist = (): void => {
277
315
  if (!storage) return;
316
+ // A disposed queue no longer owns this slot — a replacement may. See `dispose`.
317
+ if (disposed) return;
278
318
  // NEVER write over a blob that has not been read yet. See `backlogUnread`.
279
319
  if (backlogUnread) return;
280
320
  try {
@@ -289,9 +329,36 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
289
329
  }
290
330
  };
291
331
 
332
+ /**
333
+ * The two lifecycle events are the funnel's DENOMINATOR: the server computes `min_sessions` by
334
+ * counting distinct `app.session_started` grouped by `device_key`, and `app.first_open` is the top
335
+ * of the activation funnel. Losing one is not "an event fewer", it is an app-open that never
336
+ * happened as far as every rule reading them is concerned.
337
+ */
338
+ const isCountedOpenEvent = (event: ClientEvent): boolean =>
339
+ typeof event.question_key === "string" && event.question_key.startsWith("app.");
340
+
292
341
  const enforceSizeCap = (): void => {
293
- // Drop the OLDEST first so the newest events are never the ones lost under pressure.
294
- if (pending.length > maxSize) pending.splice(0, pending.length - maxSize);
342
+ let overflow = pending.length - maxSize;
343
+ if (overflow <= 0) return;
344
+ // Drop the OLDEST first so the newest events are never the ones lost under pressure — but spend
345
+ // ORDINARY events first, because at an app-open the lifecycle pair IS the oldest thing in the
346
+ // buffer. A host on the documented shared-sink wiring that goes offline for a long session
347
+ // pushes past the cap on screen views alone, and pure drop-oldest evicted `app.first_open` +
348
+ // `app.session_started` before anything else — deleting the open from the funnel while 200
349
+ // screen views survived.
350
+ const kept: QueuedItem[] = [];
351
+ for (const item of pending) {
352
+ if (overflow > 0 && !isCountedOpenEvent(item.event)) {
353
+ overflow--;
354
+ continue;
355
+ }
356
+ kept.push(item);
357
+ }
358
+ // Only when the buffer is NOTHING BUT counted events does the cap fall on them, oldest first.
359
+ // The cap is a hard ceiling: protecting a class must never turn it into an unbounded buffer.
360
+ if (overflow > 0) kept.splice(0, overflow);
361
+ pending = kept;
295
362
  };
296
363
 
297
364
  const safeSig = (event: ClientEvent): string => {
@@ -366,9 +433,18 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
366
433
  });
367
434
  return;
368
435
  }
436
+ backlogUnread = false;
369
437
  mergePersisted(parsePersisted(raced));
438
+ // Persist explicitly: writes were suppressed for the whole read window, and `mergePersisted`
439
+ // returns early (without persisting) when the backlog was empty — so without this an event
440
+ // enqueued during the window would sit in memory undurable until the next enqueue.
441
+ persist();
370
442
  } catch {
371
- // Unreadable backlog → start empty; nothing enqueued in-memory is lost.
443
+ // Unreadable backlog → start empty; nothing enqueued in-memory is lost. Writes must resume,
444
+ // or the suppression that protected the unread blob would outlive the read for the whole
445
+ // process and nothing would ever persist again.
446
+ backlogUnread = false;
447
+ persist();
372
448
  }
373
449
  })();
374
450
 
@@ -417,11 +493,15 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
417
493
  };
418
494
 
419
495
  const drain = async (): Promise<void> => {
496
+ if (disposed) return;
420
497
  try {
421
498
  await loadPromise;
422
499
  } catch {
423
500
  // load already swallows; guard the await defensively.
424
501
  }
502
+ // Re-checked AFTER the await: the owner can go away while the cold-start read is in flight, and
503
+ // a drain that resumes then would send and dequeue under a key another queue now owns.
504
+ if (disposed) return;
425
505
  if (flushing) return;
426
506
  flushing = true;
427
507
  try {
@@ -453,6 +533,9 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
453
533
  };
454
534
 
455
535
  const enqueue = (event: ClientEvent): void => {
536
+ // A disposed queue is inert: it cannot persist (the slot belongs to its replacement) and must
537
+ // not send, so buffering here would only pretend to accept the event.
538
+ if (disposed) return;
456
539
  try {
457
540
  const stamped = stamp(event);
458
541
  const sig = safeSig(stamped);
@@ -479,5 +562,17 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
479
562
 
480
563
  const size = (): number => pending.length;
481
564
 
482
- return { enqueue, flush, notifyOnline, size };
565
+ const dispose = (): void => {
566
+ if (disposed) return;
567
+ disposed = true;
568
+ // The retry is the dangerous half: `unrefTimer` is a Node-only affordance (Hermes has no
569
+ // `unref`), so on a device the backoff timer of an unmounted queue is fully alive, up to six
570
+ // attempts and a 30s ceiling. Left running it wakes up, drains, empties, and `removeItem`s a
571
+ // slot the live queue is using.
572
+ clearRetry();
573
+ // Only claimed when there IS storage (see the claim above), so only released then.
574
+ if (storage) releaseQueueKey(key);
575
+ };
576
+
577
+ return { enqueue, flush, notifyOnline, size, dispose };
483
578
  };
@@ -341,22 +341,46 @@ export const reportClientEvent = (
341
341
  export const reportClientEventsAwait = async (
342
342
  target: ClientEventTarget | undefined,
343
343
  events: ClientEvent[],
344
- ): Promise<boolean> => {
344
+ ): Promise<boolean> => (await reportClientEventsOutcome(target, events)) === "delivered";
345
+
346
+ /**
347
+ * WHY a failed send is not one thing. `false` collapses two situations that call for OPPOSITE
348
+ * responses, and a caller that wants to make the event durable has to tell them apart:
349
+ *
350
+ * • `unreachable` — the request never got an answer (no `fetch`, a network error, a non-2xx).
351
+ * Nothing is wrong with the EVENT. Retrying later is exactly right, and is what every
352
+ * offline-first path in this kit does.
353
+ * • `refused` — the server READ the event and threw it away (HTTP 200 with `skipped > 0`,
354
+ * a per-event validation failure), or there is no transport to build a request from at all.
355
+ * Retrying cannot change the answer: buffering here would park a poison event in a PERSISTED
356
+ * backlog that re-sends and is re-refused on every drain and every launch, forever.
357
+ *
358
+ * The boolean sibling above is this function with the two collapsed back together, so there is one
359
+ * implementation and the two can never drift.
360
+ */
361
+ export type SendOutcome = "delivered" | "refused" | "unreachable";
362
+
363
+ export const reportClientEventsOutcome = async (
364
+ target: ClientEventTarget | undefined,
365
+ events: ClientEvent[],
366
+ ): Promise<SendOutcome> => {
345
367
  try {
346
368
  const req = buildEventsRequest(target, events);
347
- if (!req) return false;
369
+ // No target / nothing serializable: there is no endpoint to retry against.
370
+ if (!req) return "refused";
348
371
  const res = await fetch(req.url, req.init);
349
- if (!res || !res.ok) return false;
372
+ if (!res || !res.ok) return "unreachable";
350
373
  // ONE body read, used for both the verdict and the dev warning — it can only be read once.
351
374
  const ack = await readEventsAck(res);
352
- if (!ack || ack.skipped <= 0) return true;
375
+ if (!ack || ack.skipped <= 0) return "delivered";
353
376
  if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
354
377
  console.warn(describeDiscarded(ack));
355
378
  }
356
- return false;
379
+ // The server answered and declined THIS event. A retry is a re-decline.
380
+ return "refused";
357
381
  } catch {
358
- // Unreachable / missing-fetch / network — best-effort, report failure.
359
- return false;
382
+ // Unreachable / missing-fetch / network — best-effort, and retryable.
383
+ return "unreachable";
360
384
  }
361
385
  };
362
386
 
@@ -36,6 +36,11 @@ export const useAnalytics = (
36
36
  const currentKeys = `${config.serverUrl}|${config.apiKey}|${config.appId}`;
37
37
  if (!ref.current || prevKeys.current !== currentKeys) {
38
38
  prevKeys.current = currentKeys;
39
+ // The instance being REPLACED (credentials changed, or an `appId` that arrived async) owns a
40
+ // claimed storage slot and a live backoff timer. Hand them back before building the replacement,
41
+ // or the new instance rotates onto a `…#2` key that no later launch reads, and the old one's
42
+ // retry can still wake up and `removeItem` the slot the new one is using.
43
+ ref.current?.dispose();
39
44
  ref.current = createAnalytics(config, options);
40
45
  }
41
46
 
@@ -59,5 +64,17 @@ export const useAnalytics = (
59
64
  if (hostDeviceKey) ref.current?.setUserContext({ deviceKey: hostDeviceKey });
60
65
  }, [hostDeviceKey]);
61
66
 
67
+ // UNMOUNT: give the storage claim and the retry timer back. A screen that mounts this hook is
68
+ // built and torn down repeatedly (navigation, Fast Refresh, StrictMode's double-invoke), and each
69
+ // rebuild used to leave the previous queue holding the appId's slot — so every remount after the
70
+ // first persisted into `…#2`, `…#3`, and the next launch read none of them.
71
+ useEffect(
72
+ () => () => {
73
+ ref.current?.dispose();
74
+ ref.current = undefined;
75
+ },
76
+ [],
77
+ );
78
+
62
79
  return ref.current;
63
80
  };
@@ -64,9 +64,10 @@ export const mintDeviceId = (): string => {
64
64
  // RESIDUAL WINDOW: the storage read is async, so an event emitted in the milliseconds before
65
65
  // hydration completes carries the freshly minted id rather than the persisted one. The registry makes
66
66
  // every surface agree on WHICH id that is; it does not make the read sync. A caller that can afford to
67
- // wait (the lifecycle hook's mount effect — see `hydrateAutoDeviceKey`) should await instead: its two
68
- // events are the ONLY ones the server counts `min_sessions` from, so a per-launch id there is not a
69
- // millisecond of noise, it is a counter that can never exceed 1.
67
+ // wait (`useLifecycleEvents` / `useSessionStart`, both app-open paths — see `hydrateDeviceIdentity`)
68
+ // must await instead: their events are the ONLY ones the server counts `min_sessions` from, so a
69
+ // per-launch id there is not a millisecond of noise, it is a counter that can never exceed 1. Waiting
70
+ // is only half of it — the settled outcome can still be NON-DURABLE, and those callers refuse it.
70
71
 
71
72
  /**
72
73
  * Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle.
@@ -238,6 +239,12 @@ export const resolveAutoDeviceKey = (opts: ResolveAutoDeviceKeyOptions = {}): st
238
239
  *
239
240
  * Never throws or rejects: a missing, hung, or rejecting adapter resolves to the in-memory id, and
240
241
  * with no `storage` it resolves immediately (there is nothing to hydrate from).
242
+ *
243
+ * ⚠️ IT RETURNS A BARE STRING, so it CANNOT say whether the id survives the launch — a degraded
244
+ * adapter resolves to the in-memory mint and reads identically to a persisted one. No kit surface
245
+ * uses it any more (0.14.0 moved the two lifecycle hooks off it): a caller writing a cross-launch
246
+ * join key wants {@link hydrateDeviceIdentity} and its `durable` flag. Kept as public API for a host
247
+ * that only wants "the id", never as the way to decide whether to stamp one.
241
248
  */
242
249
  export const hydrateAutoDeviceKey = async (
243
250
  opts: ResolveAutoDeviceKeyOptions = {},
@@ -15,6 +15,7 @@
15
15
  * error. A broken storage adapter must never gate or break onboarding.
16
16
  */
17
17
  import {
18
+ READ_TIMED_OUT,
18
19
  READ_TIMEOUT_MS,
19
20
  withTimeout,
20
21
  type WireOnboardingStorage,
@@ -54,7 +55,17 @@ export const loadSettledPermissions = async (
54
55
  sessionId: string,
55
56
  ): Promise<string[]> => {
56
57
  try {
57
- return readSettledPermissions(await withTimeout(storage.getItem(key), READ_TIMEOUT_MS), sessionId);
58
+ const raw = await withTimeout(storage.getItem(key), READ_TIMEOUT_MS);
59
+ // A timed-out read degrades to "nothing settled yet", the same answer as before this sentinel
60
+ // existed (the symbol used to fall through `readSettledPermissions`'s JSON.parse and be caught).
61
+ // Stated explicitly rather than left to an accident.
62
+ //
63
+ // ⚠️ KNOWN, NOT FIXED HERE: the CALLER (`WireOnboarding`) then persists the set it grows from
64
+ // this `[]`, so a timed-out read can shrink the stored set to one id. It is the same class as
65
+ // the two sites above, but the fix is a UX ruling, not a mechanical one — on an unknown set,
66
+ // does a resumed flow re-ask a permission or skip it? Reported, deliberately not guessed at.
67
+ if (raw === READ_TIMED_OUT) return [];
68
+ return readSettledPermissions(raw, sessionId);
58
69
  } catch {
59
70
  return [];
60
71
  }