@wireai/activation 0.9.0 → 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/CHANGELOG.md +39 -0
- package/dist/analytics/index.d.mts +3 -3
- package/dist/analytics/index.d.ts +3 -3
- package/dist/analytics/index.js +9 -5
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +9 -5
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-Bs2JfTJ8.d.mts → currentSession-CRRQLbAq.d.mts} +16 -0
- package/dist/{currentSession-61dcm3V-.d.ts → currentSession-Dj0cN3Rg.d.ts} +16 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +13 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +13 -7
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.js +2 -1
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +2 -1
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +1 -1
- package/dist/reviews/index.d.ts +1 -1
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/{transport-DzU-TqZz.d.ts → transport-B31G0Cib.d.ts} +51 -9
- package/dist/{transport-f5VVB5hH.d.mts → transport-Bzb-bcB2.d.mts} +51 -9
- package/package.json +1 -1
- package/src/WireOnboarding.tsx +15 -4
- package/src/analytics/eventQueue.ts +28 -9
- package/src/questionnaire/transport.ts +9 -1
- package/src/reviews/transport.ts +51 -9
- package/src/session/persistedSession.ts +17 -0
- package/src/types.ts +16 -0
|
@@ -20,17 +20,42 @@ import { k as ReviewTarget, g as ReviewDecisionResponse, j as ReviewSubmission }
|
|
|
20
20
|
* a missing `fetch`, or a network failure is swallowed and the call returns immediately.
|
|
21
21
|
*/
|
|
22
22
|
declare const submitReview: (target: ReviewTarget | undefined, review: ReviewSubmission) => void;
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Options for the best-effort review decision fetch.
|
|
25
|
+
*
|
|
26
|
+
* ── WHAT THE DEPLOYED SERVER ACTUALLY READS (verified 2026-07-17) ────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* `GET /v1/reviews/decision` declares exactly two query params — `session_id` and
|
|
29
|
+
* `device_key` — plus the `Authorization` header. That is the whole wire. Verified against
|
|
30
|
+
* the deployed OpenAPI schema, not against intent.
|
|
31
|
+
*
|
|
32
|
+
* This type is CLOSED on purpose. Hosts that hand-rolled this fetch invented `user_id` and
|
|
33
|
+
* `session_count` query params believing "the server ignores what it doesn't read, so passing
|
|
34
|
+
* it is always safe". Both are no-ops: the route declares neither. `session_count` is real, but
|
|
35
|
+
* only on the questionnaire POST body — which is precisely how it copy-pasted its way into a
|
|
36
|
+
* reviews call site and sat there doing nothing. They cost a wire lie — a call site that reads as though
|
|
37
|
+
* identity and a session counter reach the firing brain when neither does. So they are not
|
|
38
|
+
* offered here. Pass identity as `deviceKey`; the session count the server reasons about is the
|
|
39
|
+
* one IT derives from the event stream keyed by `deviceKey`, not one the client asserts.
|
|
40
|
+
*
|
|
41
|
+
* The client-side session floor is a LOCAL rule, not a wire param: use `ReviewConfig.minSessions`
|
|
42
|
+
* (evaluated by `useReviewGate` against the kit's own `wire_review_<id>_sessions` counter).
|
|
43
|
+
*/
|
|
24
44
|
interface FetchReviewDecisionOptions {
|
|
25
45
|
/**
|
|
26
46
|
* The onboarding session id, when there IS one. OPTIONAL on purpose: the review gate lives on
|
|
27
47
|
* the home feed, where a user legitimately has no onboarding session. `deviceKey` is the real
|
|
28
|
-
* identity for this call. (
|
|
29
|
-
*
|
|
30
|
-
*
|
|
48
|
+
* identity for this call. (The server relaxed `session_id` to optional on 2026-07-16 and the
|
|
49
|
+
* relaxation IS deployed — the route no longer 422s without it. Permissive wire: do NOT make
|
|
50
|
+
* this required here.)
|
|
31
51
|
*/
|
|
32
52
|
sessionId?: string;
|
|
33
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* A stable, non-PII device id — THE identity for this call. The decision endpoint reads it for
|
|
55
|
+
* cooldown + min-sessions, and it is the key the server groups a device's events under. A host
|
|
56
|
+
* whose own identity is a user id passes that id here rather than reaching for a `user_id`
|
|
57
|
+
* param the route does not declare.
|
|
58
|
+
*/
|
|
34
59
|
deviceKey?: string;
|
|
35
60
|
}
|
|
36
61
|
/**
|
|
@@ -57,12 +82,29 @@ interface FetchReviewDecisionOptions {
|
|
|
57
82
|
* • else → null, and ONLY then. Null means "the server genuinely has no opinion", which is
|
|
58
83
|
* the one case where falling back to local rules is correct.
|
|
59
84
|
*
|
|
60
|
-
* Never throws: unreachable, non-2xx
|
|
61
|
-
*
|
|
62
|
-
*
|
|
85
|
+
* Never throws: unreachable, non-2xx, bad JSON, or a missing `fetch` all resolve to null. The
|
|
86
|
+
* kit does not call this internally; a host awaits it and passes the result straight to
|
|
87
|
+
* `useReviewGate({ decision })`.
|
|
88
|
+
*
|
|
89
|
+
* ── DON'T RACE THIS AGAINST A LOCAL TIMER ────────────────────────────────────────────────
|
|
90
|
+
*
|
|
91
|
+
* The return type is `ReviewDecisionResponse`, which carries `arm` alongside `{fire, reason}`.
|
|
92
|
+
* Hand the WHOLE object to the gate and echo `arm` into the submission's `meta.firing_arm`;
|
|
93
|
+
* narrowing it to `{fire, reason}` on the way through silently kills per-arm attribution
|
|
94
|
+
* across a reweighting of the experiment.
|
|
95
|
+
*
|
|
96
|
+
* A host that starts its own dwell timer in parallel with this fetch has built a race a slow
|
|
97
|
+
* server loses: the timer fires, the local rules show the prompt, and the `{fire:false}` still
|
|
98
|
+
* in flight arrives too late to stop it. Do not hand-roll that. `useReviewGate` already owns
|
|
99
|
+
* the wait — set `ReviewConfig.timeoutFallbackMs` and the local rules stay parked until either
|
|
100
|
+
* the decision lands or the window expires, whichever comes first.
|
|
63
101
|
*
|
|
64
102
|
* const decision = await fetchReviewDecision(target, { deviceKey });
|
|
65
|
-
* const gate = useReviewGate({
|
|
103
|
+
* const gate = useReviewGate({
|
|
104
|
+
* config: { id: "home", minSessions: 2, timeoutFallbackMs: 3000 },
|
|
105
|
+
* decision: decision ?? undefined, // pass it whole — keep `arm`
|
|
106
|
+
* storage,
|
|
107
|
+
* });
|
|
66
108
|
*/
|
|
67
109
|
declare const fetchReviewDecision: (target: ReviewTarget | undefined, options?: FetchReviewDecisionOptions) => Promise<ReviewDecisionResponse | null>;
|
|
68
110
|
/** Options for a reported app event. `deviceKey` groups a device's sessions server-side. */
|
|
@@ -20,17 +20,42 @@ import { k as ReviewTarget, g as ReviewDecisionResponse, j as ReviewSubmission }
|
|
|
20
20
|
* a missing `fetch`, or a network failure is swallowed and the call returns immediately.
|
|
21
21
|
*/
|
|
22
22
|
declare const submitReview: (target: ReviewTarget | undefined, review: ReviewSubmission) => void;
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Options for the best-effort review decision fetch.
|
|
25
|
+
*
|
|
26
|
+
* ── WHAT THE DEPLOYED SERVER ACTUALLY READS (verified 2026-07-17) ────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* `GET /v1/reviews/decision` declares exactly two query params — `session_id` and
|
|
29
|
+
* `device_key` — plus the `Authorization` header. That is the whole wire. Verified against
|
|
30
|
+
* the deployed OpenAPI schema, not against intent.
|
|
31
|
+
*
|
|
32
|
+
* This type is CLOSED on purpose. Hosts that hand-rolled this fetch invented `user_id` and
|
|
33
|
+
* `session_count` query params believing "the server ignores what it doesn't read, so passing
|
|
34
|
+
* it is always safe". Both are no-ops: the route declares neither. `session_count` is real, but
|
|
35
|
+
* only on the questionnaire POST body — which is precisely how it copy-pasted its way into a
|
|
36
|
+
* reviews call site and sat there doing nothing. They cost a wire lie — a call site that reads as though
|
|
37
|
+
* identity and a session counter reach the firing brain when neither does. So they are not
|
|
38
|
+
* offered here. Pass identity as `deviceKey`; the session count the server reasons about is the
|
|
39
|
+
* one IT derives from the event stream keyed by `deviceKey`, not one the client asserts.
|
|
40
|
+
*
|
|
41
|
+
* The client-side session floor is a LOCAL rule, not a wire param: use `ReviewConfig.minSessions`
|
|
42
|
+
* (evaluated by `useReviewGate` against the kit's own `wire_review_<id>_sessions` counter).
|
|
43
|
+
*/
|
|
24
44
|
interface FetchReviewDecisionOptions {
|
|
25
45
|
/**
|
|
26
46
|
* The onboarding session id, when there IS one. OPTIONAL on purpose: the review gate lives on
|
|
27
47
|
* the home feed, where a user legitimately has no onboarding session. `deviceKey` is the real
|
|
28
|
-
* identity for this call. (
|
|
29
|
-
*
|
|
30
|
-
*
|
|
48
|
+
* identity for this call. (The server relaxed `session_id` to optional on 2026-07-16 and the
|
|
49
|
+
* relaxation IS deployed — the route no longer 422s without it. Permissive wire: do NOT make
|
|
50
|
+
* this required here.)
|
|
31
51
|
*/
|
|
32
52
|
sessionId?: string;
|
|
33
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* A stable, non-PII device id — THE identity for this call. The decision endpoint reads it for
|
|
55
|
+
* cooldown + min-sessions, and it is the key the server groups a device's events under. A host
|
|
56
|
+
* whose own identity is a user id passes that id here rather than reaching for a `user_id`
|
|
57
|
+
* param the route does not declare.
|
|
58
|
+
*/
|
|
34
59
|
deviceKey?: string;
|
|
35
60
|
}
|
|
36
61
|
/**
|
|
@@ -57,12 +82,29 @@ interface FetchReviewDecisionOptions {
|
|
|
57
82
|
* • else → null, and ONLY then. Null means "the server genuinely has no opinion", which is
|
|
58
83
|
* the one case where falling back to local rules is correct.
|
|
59
84
|
*
|
|
60
|
-
* Never throws: unreachable, non-2xx
|
|
61
|
-
*
|
|
62
|
-
*
|
|
85
|
+
* Never throws: unreachable, non-2xx, bad JSON, or a missing `fetch` all resolve to null. The
|
|
86
|
+
* kit does not call this internally; a host awaits it and passes the result straight to
|
|
87
|
+
* `useReviewGate({ decision })`.
|
|
88
|
+
*
|
|
89
|
+
* ── DON'T RACE THIS AGAINST A LOCAL TIMER ────────────────────────────────────────────────
|
|
90
|
+
*
|
|
91
|
+
* The return type is `ReviewDecisionResponse`, which carries `arm` alongside `{fire, reason}`.
|
|
92
|
+
* Hand the WHOLE object to the gate and echo `arm` into the submission's `meta.firing_arm`;
|
|
93
|
+
* narrowing it to `{fire, reason}` on the way through silently kills per-arm attribution
|
|
94
|
+
* across a reweighting of the experiment.
|
|
95
|
+
*
|
|
96
|
+
* A host that starts its own dwell timer in parallel with this fetch has built a race a slow
|
|
97
|
+
* server loses: the timer fires, the local rules show the prompt, and the `{fire:false}` still
|
|
98
|
+
* in flight arrives too late to stop it. Do not hand-roll that. `useReviewGate` already owns
|
|
99
|
+
* the wait — set `ReviewConfig.timeoutFallbackMs` and the local rules stay parked until either
|
|
100
|
+
* the decision lands or the window expires, whichever comes first.
|
|
63
101
|
*
|
|
64
102
|
* const decision = await fetchReviewDecision(target, { deviceKey });
|
|
65
|
-
* const gate = useReviewGate({
|
|
103
|
+
* const gate = useReviewGate({
|
|
104
|
+
* config: { id: "home", minSessions: 2, timeoutFallbackMs: 3000 },
|
|
105
|
+
* decision: decision ?? undefined, // pass it whole — keep `arm`
|
|
106
|
+
* storage,
|
|
107
|
+
* });
|
|
66
108
|
*/
|
|
67
109
|
declare const fetchReviewDecision: (target: ReviewTarget | undefined, options?: FetchReviewDecisionOptions) => Promise<ReviewDecisionResponse | null>;
|
|
68
110
|
/** Options for a reported app event. `deviceKey` groups a device's sessions server-side. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.9.
|
|
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>",
|
package/src/WireOnboarding.tsx
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
284
|
-
if (
|
|
301
|
+
const result = await postBatch(batch.map((item) => item.event));
|
|
302
|
+
if (result === "retry") {
|
|
285
303
|
scheduleRetry();
|
|
286
304
|
return;
|
|
287
305
|
}
|
|
288
|
-
//
|
|
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
|
-
|
|
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;
|
package/src/reviews/transport.ts
CHANGED
|
@@ -39,17 +39,42 @@ export const submitReview = (
|
|
|
39
39
|
}
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Options for the best-effort review decision fetch.
|
|
44
|
+
*
|
|
45
|
+
* ── WHAT THE DEPLOYED SERVER ACTUALLY READS (verified 2026-07-17) ────────────────────────
|
|
46
|
+
*
|
|
47
|
+
* `GET /v1/reviews/decision` declares exactly two query params — `session_id` and
|
|
48
|
+
* `device_key` — plus the `Authorization` header. That is the whole wire. Verified against
|
|
49
|
+
* the deployed OpenAPI schema, not against intent.
|
|
50
|
+
*
|
|
51
|
+
* This type is CLOSED on purpose. Hosts that hand-rolled this fetch invented `user_id` and
|
|
52
|
+
* `session_count` query params believing "the server ignores what it doesn't read, so passing
|
|
53
|
+
* it is always safe". Both are no-ops: the route declares neither. `session_count` is real, but
|
|
54
|
+
* only on the questionnaire POST body — which is precisely how it copy-pasted its way into a
|
|
55
|
+
* reviews call site and sat there doing nothing. They cost a wire lie — a call site that reads as though
|
|
56
|
+
* identity and a session counter reach the firing brain when neither does. So they are not
|
|
57
|
+
* offered here. Pass identity as `deviceKey`; the session count the server reasons about is the
|
|
58
|
+
* one IT derives from the event stream keyed by `deviceKey`, not one the client asserts.
|
|
59
|
+
*
|
|
60
|
+
* The client-side session floor is a LOCAL rule, not a wire param: use `ReviewConfig.minSessions`
|
|
61
|
+
* (evaluated by `useReviewGate` against the kit's own `wire_review_<id>_sessions` counter).
|
|
62
|
+
*/
|
|
43
63
|
export interface FetchReviewDecisionOptions {
|
|
44
64
|
/**
|
|
45
65
|
* The onboarding session id, when there IS one. OPTIONAL on purpose: the review gate lives on
|
|
46
66
|
* the home feed, where a user legitimately has no onboarding session. `deviceKey` is the real
|
|
47
|
-
* identity for this call. (
|
|
48
|
-
*
|
|
49
|
-
*
|
|
67
|
+
* identity for this call. (The server relaxed `session_id` to optional on 2026-07-16 and the
|
|
68
|
+
* relaxation IS deployed — the route no longer 422s without it. Permissive wire: do NOT make
|
|
69
|
+
* this required here.)
|
|
50
70
|
*/
|
|
51
71
|
sessionId?: string;
|
|
52
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* A stable, non-PII device id — THE identity for this call. The decision endpoint reads it for
|
|
74
|
+
* cooldown + min-sessions, and it is the key the server groups a device's events under. A host
|
|
75
|
+
* whose own identity is a user id passes that id here rather than reaching for a `user_id`
|
|
76
|
+
* param the route does not declare.
|
|
77
|
+
*/
|
|
53
78
|
deviceKey?: string;
|
|
54
79
|
}
|
|
55
80
|
|
|
@@ -77,12 +102,29 @@ export interface FetchReviewDecisionOptions {
|
|
|
77
102
|
* • else → null, and ONLY then. Null means "the server genuinely has no opinion", which is
|
|
78
103
|
* the one case where falling back to local rules is correct.
|
|
79
104
|
*
|
|
80
|
-
* Never throws: unreachable, non-2xx
|
|
81
|
-
*
|
|
82
|
-
*
|
|
105
|
+
* Never throws: unreachable, non-2xx, bad JSON, or a missing `fetch` all resolve to null. The
|
|
106
|
+
* kit does not call this internally; a host awaits it and passes the result straight to
|
|
107
|
+
* `useReviewGate({ decision })`.
|
|
108
|
+
*
|
|
109
|
+
* ── DON'T RACE THIS AGAINST A LOCAL TIMER ────────────────────────────────────────────────
|
|
110
|
+
*
|
|
111
|
+
* The return type is `ReviewDecisionResponse`, which carries `arm` alongside `{fire, reason}`.
|
|
112
|
+
* Hand the WHOLE object to the gate and echo `arm` into the submission's `meta.firing_arm`;
|
|
113
|
+
* narrowing it to `{fire, reason}` on the way through silently kills per-arm attribution
|
|
114
|
+
* across a reweighting of the experiment.
|
|
115
|
+
*
|
|
116
|
+
* A host that starts its own dwell timer in parallel with this fetch has built a race a slow
|
|
117
|
+
* server loses: the timer fires, the local rules show the prompt, and the `{fire:false}` still
|
|
118
|
+
* in flight arrives too late to stop it. Do not hand-roll that. `useReviewGate` already owns
|
|
119
|
+
* the wait — set `ReviewConfig.timeoutFallbackMs` and the local rules stay parked until either
|
|
120
|
+
* the decision lands or the window expires, whichever comes first.
|
|
83
121
|
*
|
|
84
122
|
* const decision = await fetchReviewDecision(target, { deviceKey });
|
|
85
|
-
* const gate = useReviewGate({
|
|
123
|
+
* const gate = useReviewGate({
|
|
124
|
+
* config: { id: "home", minSessions: 2, timeoutFallbackMs: 3000 },
|
|
125
|
+
* decision: decision ?? undefined, // pass it whole — keep `arm`
|
|
126
|
+
* storage,
|
|
127
|
+
* });
|
|
86
128
|
*/
|
|
87
129
|
export const fetchReviewDecision = async (
|
|
88
130
|
target: ReviewTarget | undefined,
|
|
@@ -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. */
|