@wireai/activation 0.9.1 → 0.9.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
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 ?? "";
@@ -224,10 +224,25 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
224
224
  }
225
225
  })();
226
226
 
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;
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";
231
246
  const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
232
247
  const timer = setTimeout(() => controller?.abort(), 15_000);
233
248
  try {
@@ -240,9 +255,12 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
240
255
  body: JSON.stringify({ events }),
241
256
  signal: controller?.signal,
242
257
  });
243
- return !!(res && (res as { ok?: boolean }).ok);
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";
244
262
  } catch {
245
- return false;
263
+ return "retry";
246
264
  } finally {
247
265
  clearTimeout(timer);
248
266
  }
@@ -280,12 +298,13 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
280
298
  try {
281
299
  while (pending.length > 0) {
282
300
  const batch = pending.slice(0, batchSize);
283
- const ok = await postBatch(batch.map((item) => item.event));
284
- if (!ok) {
301
+ const result = await postBatch(batch.map((item) => item.event));
302
+ if (result === "retry") {
285
303
  scheduleRetry();
286
304
  return;
287
305
  }
288
- // Dequeue exactly the acked batch by id (pending may have grown while in flight).
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.
289
308
  const acked = new Set(batch.map((item) => item.id));
290
309
  pending = pending.filter((item) => !acked.has(item.id));
291
310
  persist();
@@ -75,7 +75,15 @@ 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
- return json ?? 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;
79
87
  } catch {
80
88
  /* unreachable / non-2xx / bad JSON / missing-fetch - never show */
81
89
  return null;
@@ -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. */