@wireai/activation 0.14.3 → 0.16.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/AGENTS.md +96 -21
- package/CHANGELOG.md +780 -0
- package/INTEGRATION_PROMPT.md +61 -23
- package/README.md +110 -31
- package/dist/analytics/index.d.mts +35 -13
- package/dist/analytics/index.d.ts +35 -13
- package/dist/analytics/index.js +288 -127
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +288 -127
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +15 -1
- package/dist/coachmarks/index.d.ts +15 -1
- package/dist/coachmarks/index.js +120 -22
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +120 -22
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-CUvTOchb.d.mts → currentSession-Bz7G6lno.d.mts} +35 -39
- package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-z-CZ55ad.d.ts} +35 -39
- package/dist/{decision-Bgo17oH7.d.mts → decision-3vWLuBlO.d.ts} +11 -2
- package/dist/{decision-Bkh_LigV.d.ts → decision-yBj2AyPW.d.mts} +11 -2
- package/dist/index.d.mts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +125 -36
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +125 -36
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +3 -16
- package/dist/questionnaire/index.d.ts +3 -16
- package/dist/questionnaire/index.js +202 -46
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +203 -47
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +10 -6
- package/dist/reviews/index.d.ts +10 -6
- package/dist/reviews/index.js +269 -52
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +270 -53
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.d.mts +1 -1
- package/dist/showcase/index.d.ts +1 -1
- package/dist/showcase/index.js +106 -20
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +107 -21
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/{transport-j5gFfJhK.d.mts → transport-s5QxA-ci.d.mts} +18 -13
- package/dist/{transport-B_0SgCBe.d.ts → transport-xqqSFqxs.d.ts} +18 -13
- package/dist/{types-Cju-1_jT.d.mts → types-Byx306Kv.d.mts} +25 -10
- package/dist/{types-BcmagF6K.d.mts → types-D_0B0yay.d.mts} +7 -2
- package/dist/{types-BcmagF6K.d.ts → types-D_0B0yay.d.ts} +7 -2
- package/dist/{types-h2BZvl1t.d.ts → types-tdATL5z0.d.ts} +25 -10
- package/llms.txt +9 -9
- package/package.json +6 -9
- package/src/WireOnboarding.tsx +4 -1
- package/src/analytics/currentSession.ts +141 -4
- package/src/analytics/index.ts +6 -1
- package/src/analytics/reportClientEvent.ts +19 -10
- package/src/analytics/useAnalytics.ts +74 -15
- package/src/analytics/wireDoctor.ts +152 -7
- package/src/coachmarks/CoachmarkProvider.tsx +26 -5
- package/src/coachmarks/runtime.ts +156 -2
- package/src/coachmarks/types.ts +7 -2
- package/src/coachmarks/useCoachmarkTour.ts +51 -1
- package/src/context/deviceId.ts +72 -6
- package/src/features/WireFeaturesProvider.tsx +72 -12
- package/src/features/fetchWireFeatures.ts +49 -11
- package/src/features/useWireFeatures.ts +39 -3
- package/src/identity/identityRecord.ts +15 -2
- package/src/questionnaire/QuestionnaireGate.tsx +40 -1
- package/src/questionnaire/transport.ts +22 -8
- package/src/questionnaire/useQuestionnaireGate.ts +58 -7
- package/src/reviews/ReviewGate.tsx +125 -37
- package/src/reviews/decision.ts +11 -1
- package/src/reviews/idempotency.ts +109 -0
- package/src/reviews/index.ts +4 -2
- package/src/reviews/runtime.ts +44 -13
- package/src/reviews/transport.ts +39 -20
- package/src/reviews/types.ts +7 -0
- package/src/reviews/useReviewGate.ts +57 -7
- package/src/session-analytics/lifecycle.ts +16 -0
- package/src/session-analytics/useLifecycleEvents.ts +30 -2
- package/src/session-analytics/useSessionStart.ts +22 -2
- package/src/showcase/FeatureShowcase.tsx +50 -3
- package/src/types.ts +15 -8
- package/src/utils/submitResult.ts +17 -9
- package/src/utils/withDeadline.ts +70 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { k as ReviewTarget, S as SubmitResult, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-
|
|
1
|
+
import { k as ReviewTarget, S as SubmitResult, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-Byx306Kv.mjs';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* What became of a review POST — the SHARED three-outcome verdict, re-exported under the name the
|
|
@@ -25,23 +25,28 @@ type ReviewSubmitResult = SubmitResult;
|
|
|
25
25
|
*
|
|
26
26
|
* It was, for exactly one unpublished release, and the boolean was the bug. `false` meant both
|
|
27
27
|
* "nothing reached the server" and "the server answered non-2xx", and the one caller that reads
|
|
28
|
-
* this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
28
|
+
* this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. At the time there was
|
|
29
|
+
* no idempotency key on the wire at all, so a 502 returned AFTER the insert commits meant the
|
|
30
|
+
* re-post wrote a SECOND row, double-counting `count` and corrupting `avg` — the precise corruption
|
|
31
|
+
* the one-row latch exists to prevent. A response of any status proves the server was reached, and
|
|
32
|
+
* that is a different question from whether it liked the row. So the two are different values, and
|
|
33
|
+
* they stay different: the split is what lets the caller decide, and the retry-on-`rejected`
|
|
34
|
+
* question is a recovery-budget decision the key does not answer on its own.
|
|
34
35
|
*
|
|
35
36
|
* ── THE RESIDUAL, STATED HONESTLY ────────────────────────────────────────────────────────────
|
|
36
37
|
*
|
|
37
38
|
* `unsent` is not proof the server never got the row. A connection dropped after the request was
|
|
38
39
|
* written — or after the row committed — surfaces as a thrown/rejected `fetch` here, exactly like
|
|
39
|
-
* an offline device.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
40
|
+
* an offline device. That used to make the `unsent` re-post SAFER rather than SAFE, and it is what
|
|
41
|
+
* the client-minted idempotency key now covers: `ReviewSubmission.idempotency_key` reaches
|
|
42
|
+
* `CreateReviewRequest`, and the server upserts the derived row rather than inserting a second one.
|
|
43
|
+
*
|
|
44
|
+
* IT IS A CAPABILITY, NOT A GUARANTEE, and the condition is not ours to enforce from in here. The
|
|
45
|
+
* server honours the key only when the post also carries a unit it can scope it to —
|
|
46
|
+
* `meta.device_key`, else `session_id` — and ignores it outright otherwise. `ReviewGate` fills the
|
|
47
|
+
* device key in for exactly this reason, but this function posts whatever body it is handed, so a
|
|
48
|
+
* caller that assembles its own body without a unit is back in the old world where a lost detractor
|
|
49
|
+
* is a gap in the data and a duplicated one is a lie in the data.
|
|
45
50
|
*/
|
|
46
51
|
declare const submitReview: (target: ReviewTarget | undefined, review: ReviewSubmission) => Promise<ReviewSubmitResult>;
|
|
47
52
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { k as ReviewTarget, S as SubmitResult, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-
|
|
1
|
+
import { k as ReviewTarget, S as SubmitResult, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-tdATL5z0.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* What became of a review POST — the SHARED three-outcome verdict, re-exported under the name the
|
|
@@ -25,23 +25,28 @@ type ReviewSubmitResult = SubmitResult;
|
|
|
25
25
|
*
|
|
26
26
|
* It was, for exactly one unpublished release, and the boolean was the bug. `false` meant both
|
|
27
27
|
* "nothing reached the server" and "the server answered non-2xx", and the one caller that reads
|
|
28
|
-
* this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
28
|
+
* this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. At the time there was
|
|
29
|
+
* no idempotency key on the wire at all, so a 502 returned AFTER the insert commits meant the
|
|
30
|
+
* re-post wrote a SECOND row, double-counting `count` and corrupting `avg` — the precise corruption
|
|
31
|
+
* the one-row latch exists to prevent. A response of any status proves the server was reached, and
|
|
32
|
+
* that is a different question from whether it liked the row. So the two are different values, and
|
|
33
|
+
* they stay different: the split is what lets the caller decide, and the retry-on-`rejected`
|
|
34
|
+
* question is a recovery-budget decision the key does not answer on its own.
|
|
34
35
|
*
|
|
35
36
|
* ── THE RESIDUAL, STATED HONESTLY ────────────────────────────────────────────────────────────
|
|
36
37
|
*
|
|
37
38
|
* `unsent` is not proof the server never got the row. A connection dropped after the request was
|
|
38
39
|
* written — or after the row committed — surfaces as a thrown/rejected `fetch` here, exactly like
|
|
39
|
-
* an offline device.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
40
|
+
* an offline device. That used to make the `unsent` re-post SAFER rather than SAFE, and it is what
|
|
41
|
+
* the client-minted idempotency key now covers: `ReviewSubmission.idempotency_key` reaches
|
|
42
|
+
* `CreateReviewRequest`, and the server upserts the derived row rather than inserting a second one.
|
|
43
|
+
*
|
|
44
|
+
* IT IS A CAPABILITY, NOT A GUARANTEE, and the condition is not ours to enforce from in here. The
|
|
45
|
+
* server honours the key only when the post also carries a unit it can scope it to —
|
|
46
|
+
* `meta.device_key`, else `session_id` — and ignores it outright otherwise. `ReviewGate` fills the
|
|
47
|
+
* device key in for exactly this reason, but this function posts whatever body it is handed, so a
|
|
48
|
+
* caller that assembles its own body without a unit is back in the old world where a lost detractor
|
|
49
|
+
* is a gap in the data and a duplicated one is a lie in the data.
|
|
45
50
|
*/
|
|
46
51
|
declare const submitReview: (target: ReviewTarget | undefined, review: ReviewSubmission) => Promise<ReviewSubmitResult>;
|
|
47
52
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { O as OnboardingTheme } from './types-BKfpdZzX.mjs';
|
|
2
|
-
import { C as CoachmarkStorage } from './types-
|
|
2
|
+
import { C as CoachmarkStorage } from './types-D_0B0yay.mjs';
|
|
3
3
|
import { a as WireFeatures, b as WireFeaturesConfig } from './types-BpwiRpA8.mjs';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -25,20 +25,28 @@ import { a as WireFeatures, b as WireFeaturesConfig } from './types-BpwiRpA8.mjs
|
|
|
25
25
|
*
|
|
26
26
|
* `submitReview` was a boolean for exactly one unpublished release, and the boolean WAS the bug.
|
|
27
27
|
* `false` meant both "nothing reached the server" and "the server answered non-2xx", and the caller
|
|
28
|
-
* treats `false` as "still owed" and re-posts.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* treats `false` as "still owed" and re-posts. At the time neither body carried a client id, so
|
|
29
|
+
* there was NO idempotency key on the wire on either surface: a 502 returned AFTER the insert
|
|
30
|
+
* commits meant the re-post wrote a SECOND row. A response of any status proves the server was
|
|
31
|
+
* reached, and that is a different question from whether it liked the payload — which is why the
|
|
32
|
+
* three values stay, on both surfaces, whatever the wire gains.
|
|
33
33
|
*
|
|
34
34
|
* ── THE RESIDUAL, STATED HONESTLY ─────────────────────────────────────────────────────────────
|
|
35
35
|
*
|
|
36
36
|
* `unsent` is not proof the server never got it. A connection dropped after the request was written
|
|
37
37
|
* — or after the row committed — surfaces as a thrown/rejected `fetch`, exactly like an offline
|
|
38
|
-
* device.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
38
|
+
* device. Closing that window needs a client-minted idempotency key the server upserts on, and AS
|
|
39
|
+
* OF 2026-08-19 THE TWO SURFACES DIFFER — do not read this paragraph as one fact about both:
|
|
40
|
+
*
|
|
41
|
+
* • REVIEWS — closed. `CreateReviewRequest` takes an optional `idempotency_key` and the server
|
|
42
|
+
* upserts a derived row on it, so a `ReviewGate` re-post merges instead of duplicating. It is a
|
|
43
|
+
* capability, not a guarantee: the server honours the key only when the post also carries a
|
|
44
|
+
* unit to scope it to (`meta.device_key`, else `session_id`), so a caller that strips both is
|
|
45
|
+
* back to the old behaviour.
|
|
46
|
+
* • QUESTIONNAIRES — still open, and still exactly as this paragraph used to read for both. The
|
|
47
|
+
* response body carries no client id and the server has no idempotency path for it at all, so
|
|
48
|
+
* retrying only on `unsent` there is SAFER, not SAFE. Prefer losing a row over inventing one: a
|
|
49
|
+
* lost detractor is a gap in the data, a duplicated one is a lie in the data.
|
|
42
50
|
*/
|
|
43
51
|
type SubmitResult = "accepted" | "rejected" | "unsent";
|
|
44
52
|
|
|
@@ -119,6 +127,13 @@ interface ReviewSubmission {
|
|
|
119
127
|
contact?: string;
|
|
120
128
|
session_id?: string;
|
|
121
129
|
source?: string;
|
|
130
|
+
/**
|
|
131
|
+
* OPTIONAL client-minted key for the two-phase submit: two posts carrying the same key upsert ONE
|
|
132
|
+
* row instead of creating two. The server honours it ONLY when the post also carries a resolvable
|
|
133
|
+
* user — `meta.device_key`, else `session_id` — and silently ignores it otherwise, so it always
|
|
134
|
+
* travels with one of those. One key per gate impression, stable across that impression's posts.
|
|
135
|
+
*/
|
|
136
|
+
idempotency_key?: string;
|
|
122
137
|
meta?: Record<string, unknown>;
|
|
123
138
|
}
|
|
124
139
|
/**
|
|
@@ -18,8 +18,13 @@ interface TargetRect {
|
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
20
|
* The host-injected gate storage. SYNCHRONOUS on purpose — a coachmark's
|
|
21
|
-
* "seen?" gate must resolve during render
|
|
22
|
-
*
|
|
21
|
+
* "seen?" gate must resolve during render. This is ENFORCED, fail closed: a
|
|
22
|
+
* probe on first read catches an adapter whose `getItem` returns a thenable
|
|
23
|
+
* (AsyncStorage handed straight in) and substitutes a closed adapter — every
|
|
24
|
+
* gate reads "seen", nothing shows, nothing fires, one dev warning names the
|
|
25
|
+
* fix (see `validateGateStorage` in ./runtime). A raw async adapter would
|
|
26
|
+
* otherwise report every gate unseen forever: tours replay each open, and a
|
|
27
|
+
* review gate with `minSessions` 0/1 re-fires each open. MMKV's
|
|
23
28
|
* `getString`/`set` fit as-is; an in-memory `Map` wrapper works for tests. This
|
|
24
29
|
* is deliberately separate from the async `WireOnboardingStorage` used for
|
|
25
30
|
* session persistence.
|
|
@@ -18,8 +18,13 @@ interface TargetRect {
|
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
20
|
* The host-injected gate storage. SYNCHRONOUS on purpose — a coachmark's
|
|
21
|
-
* "seen?" gate must resolve during render
|
|
22
|
-
*
|
|
21
|
+
* "seen?" gate must resolve during render. This is ENFORCED, fail closed: a
|
|
22
|
+
* probe on first read catches an adapter whose `getItem` returns a thenable
|
|
23
|
+
* (AsyncStorage handed straight in) and substitutes a closed adapter — every
|
|
24
|
+
* gate reads "seen", nothing shows, nothing fires, one dev warning names the
|
|
25
|
+
* fix (see `validateGateStorage` in ./runtime). A raw async adapter would
|
|
26
|
+
* otherwise report every gate unseen forever: tours replay each open, and a
|
|
27
|
+
* review gate with `minSessions` 0/1 re-fires each open. MMKV's
|
|
23
28
|
* `getString`/`set` fit as-is; an in-memory `Map` wrapper works for tests. This
|
|
24
29
|
* is deliberately separate from the async `WireOnboardingStorage` used for
|
|
25
30
|
* session persistence.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { O as OnboardingTheme } from './types-BKfpdZzX.js';
|
|
2
|
-
import { C as CoachmarkStorage } from './types-
|
|
2
|
+
import { C as CoachmarkStorage } from './types-D_0B0yay.js';
|
|
3
3
|
import { a as WireFeatures, b as WireFeaturesConfig } from './types-BpwiRpA8.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -25,20 +25,28 @@ import { a as WireFeatures, b as WireFeaturesConfig } from './types-BpwiRpA8.js'
|
|
|
25
25
|
*
|
|
26
26
|
* `submitReview` was a boolean for exactly one unpublished release, and the boolean WAS the bug.
|
|
27
27
|
* `false` meant both "nothing reached the server" and "the server answered non-2xx", and the caller
|
|
28
|
-
* treats `false` as "still owed" and re-posts.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* treats `false` as "still owed" and re-posts. At the time neither body carried a client id, so
|
|
29
|
+
* there was NO idempotency key on the wire on either surface: a 502 returned AFTER the insert
|
|
30
|
+
* commits meant the re-post wrote a SECOND row. A response of any status proves the server was
|
|
31
|
+
* reached, and that is a different question from whether it liked the payload — which is why the
|
|
32
|
+
* three values stay, on both surfaces, whatever the wire gains.
|
|
33
33
|
*
|
|
34
34
|
* ── THE RESIDUAL, STATED HONESTLY ─────────────────────────────────────────────────────────────
|
|
35
35
|
*
|
|
36
36
|
* `unsent` is not proof the server never got it. A connection dropped after the request was written
|
|
37
37
|
* — or after the row committed — surfaces as a thrown/rejected `fetch`, exactly like an offline
|
|
38
|
-
* device.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
38
|
+
* device. Closing that window needs a client-minted idempotency key the server upserts on, and AS
|
|
39
|
+
* OF 2026-08-19 THE TWO SURFACES DIFFER — do not read this paragraph as one fact about both:
|
|
40
|
+
*
|
|
41
|
+
* • REVIEWS — closed. `CreateReviewRequest` takes an optional `idempotency_key` and the server
|
|
42
|
+
* upserts a derived row on it, so a `ReviewGate` re-post merges instead of duplicating. It is a
|
|
43
|
+
* capability, not a guarantee: the server honours the key only when the post also carries a
|
|
44
|
+
* unit to scope it to (`meta.device_key`, else `session_id`), so a caller that strips both is
|
|
45
|
+
* back to the old behaviour.
|
|
46
|
+
* • QUESTIONNAIRES — still open, and still exactly as this paragraph used to read for both. The
|
|
47
|
+
* response body carries no client id and the server has no idempotency path for it at all, so
|
|
48
|
+
* retrying only on `unsent` there is SAFER, not SAFE. Prefer losing a row over inventing one: a
|
|
49
|
+
* lost detractor is a gap in the data, a duplicated one is a lie in the data.
|
|
42
50
|
*/
|
|
43
51
|
type SubmitResult = "accepted" | "rejected" | "unsent";
|
|
44
52
|
|
|
@@ -119,6 +127,13 @@ interface ReviewSubmission {
|
|
|
119
127
|
contact?: string;
|
|
120
128
|
session_id?: string;
|
|
121
129
|
source?: string;
|
|
130
|
+
/**
|
|
131
|
+
* OPTIONAL client-minted key for the two-phase submit: two posts carrying the same key upsert ONE
|
|
132
|
+
* row instead of creating two. The server honours it ONLY when the post also carries a resolvable
|
|
133
|
+
* user — `meta.device_key`, else `session_id` — and silently ignores it otherwise, so it always
|
|
134
|
+
* travels with one of those. One key per gate impression, stable across that impression's posts.
|
|
135
|
+
*/
|
|
136
|
+
idempotency_key?: string;
|
|
122
137
|
meta?: Record<string, unknown>;
|
|
123
138
|
}
|
|
124
139
|
/**
|
package/llms.txt
CHANGED
|
@@ -8,19 +8,19 @@
|
|
|
8
8
|
|
|
9
9
|
## What an agent needs to know
|
|
10
10
|
|
|
11
|
-
- **Install:** `npm install @wireai/activation wireai-rn` (react + react-native are peers).
|
|
11
|
+
- **Install:** `npm install @wireai/activation wireai-rn zod react-native-safe-area-context` (react + react-native are peers).
|
|
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
|
-
- **Two secrets:** an app `apiKey` (a `wai_…` key that resolves the tenant server-side) and the backend `serverUrl`.
|
|
14
|
-
- **Render:** drop `<WireOnboarding config={
|
|
13
|
+
- **Two secrets:** an app `apiKey` (a `wai_…` key that resolves the tenant server-side) and the backend `serverUrl`. Both come from the console: sign up at https://getwireai.com/signup (app name + email; the confirmation link is where you choose a password, and that one step creates your first app and its key and lands you in the console already signed in), then read both off that app's Integrate tab at `https://getwireai.com/console/<app-id>/integrate`, where the key is a copy field and the server URL is the `serverUrl` line of the init snippet. The key stays readable there on every visit. Sign back in later at https://getwireai.com/login; register further apps at https://getwireai.com/console/register. Keys are per environment (dev / prod / staging). Nothing renders without both.
|
|
14
|
+
- **Render:** build the config once and gate on it (`wireConfigFromEnv` returns `null` when the env is unset, and `config` is a required non-null prop), then drop `<WireOnboarding config={config} theme={...} onComplete={persist} fallbackFlow={<YourStaticOnboarding/>} ... />` into the signup flow.
|
|
15
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(
|
|
16
|
+
- **Lifecycle (mount it once):** `useLifecycleEvents({ serverUrl, apiKey, appId, storage }, { deviceKey, sessionCount, userId })` at the app root, before anything else touches analytics. The first argument is a `LifecycleConfig` built from explicit fields, NOT the `WireOnboardingConfig` (`wireConfigFromEnv` can return `null`, and `storage` is not a config field). 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 a `storage` adapter in that first argument 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. `sessionCount` in the second argument is the HOST's obligation: a counter the app persists and increments once per open, starting at 1. A hardcoded `1` compiles and pins every user at `returning: false` forever with no error anywhere. `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
|
|
|
20
20
|
## Helpers (the reusable substrate)
|
|
21
21
|
|
|
22
22
|
- `wireConfigFromEnv({ appId, metadata? })` → config or null (reads `EXPO_PUBLIC_WIREAI_API_KEY`/`_SERVER_URL`/`_APP_ID`).
|
|
23
|
-
- `isOnboardingEnabled({ remote? })` → the standard gate (transport present: apiKey + serverUrl). No on/off env flag; `remote` is an optional kill-switch the app passes in.
|
|
23
|
+
- `isOnboardingEnabled({ remote?, config? })` → the standard gate (transport present: apiKey + serverUrl). No on/off env flag; `remote` is an optional kill-switch the app passes in, and `config` forwards `WireConfigOverrides` (a programmatic apiKey/serverUrl) to the transport-present check.
|
|
24
24
|
- `WIRE_ONBOARDING_EVENTS` + `toAnalyticsEvent(event)` → canonical `wire_onboarding_*` funnel names; log via the app's own analytics.
|
|
25
25
|
- `attributionMetadata(a)` → shape install/ad attribution into `config.metadata` (forwarded to the agent).
|
|
26
26
|
- `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.
|
|
@@ -30,10 +30,10 @@
|
|
|
30
30
|
|
|
31
31
|
The package is more than `<WireOnboarding>`. Tree-shakeable subpath exports (each keeps the onboarding UI out of an analytics-only bundle):
|
|
32
32
|
|
|
33
|
-
- `@wireai/activation/analytics`: `createAnalytics({ serverUrl, apiKey, storage })` (Segment/PostHog-shaped `track` / `screen` / `identify` / `setUserContext` / `reset` over an offline-first queue), `createScreenTracker` / `screenTrackingHandler` (auto screen views), `reportAppEvent`, `createEventQueue`, `clearUserContext` (logout). Rich `WireUserContext` supports an opt-in `userEmail` (raw by default, `hashEmail` to fold) and auto-mints a persisted per-install `device_key`. `identify` refuses email-shaped ids unless `allowEmailAsUserId`. See the README "Rich user context & PII" section. Also `wireDoctor({ target, storage })`, the DEV-ONLY self-check:
|
|
34
|
-
- `@wireai/activation/reviews`: the in-app review gate. `useReviewGate`, `fetchReviewDecision` (server AI seam; a `{fire:false}` survives intact), `ReviewGate` UI. `minSessions` defaults to 2 (never prompts on the first session; a server decision still overrides).
|
|
33
|
+
- `@wireai/activation/analytics`: `createAnalytics({ serverUrl, apiKey, storage })` (Segment/PostHog-shaped `track` / `screen` / `identify` / `setUserContext` / `reset` over an offline-first queue), `createScreenTracker` / `screenTrackingHandler` (auto screen views), `reportAppEvent`, `createEventQueue`, `clearUserContext` (logout). Rich `WireUserContext` supports an opt-in `userEmail` (raw by default, `hashEmail` to fold) and auto-mints a persisted per-install `device_key`. `identify` refuses email-shaped ids unless `allowEmailAsUserId`. See the README "Rich user context & PII" section. Also `wireDoctor({ target, storage, join })`, the DEV-ONLY self-check: five `{name, ok, detail}` checks (target shape, server reachability, storage writability, a `dry_run` round trip whose verdict is `written === 1 && skipped === 0` read from the ack body because `/v1/events` answers 200 for a batch it discards, and `join_key`). It never throws, never prints any part of the apiKey or the device key, and does nothing at all when `__DEV__` is unset. `join_key` answers whether the session will carry a `user_context.device_key` (host-supplied or the kit's auto-injected one), and it needs the `join: { appId, userContext, autoJoinKey }` argument; omitting `join` FAILS that check rather than skipping it. It is strictly read-only: no mint, no identity-registry write, nothing persisted. Read the scope of a green report: it now says events reach the server AND can be joined. `wireDoctor` still does not check that the lifecycle hook is mounted and does not check `min_sessions`, so an integration missing either reports all-green while its `activated` funnel stays permanently at zero.
|
|
34
|
+
- `@wireai/activation/reviews`: the in-app review gate. `useReviewGate`, `fetchReviewDecision` (server AI seam; a `{fire:false}` survives intact), `ReviewGate` UI. `minSessions` defaults to 2 (never prompts on the first session; a server decision still overrides). THE GATE NEEDS SYNCHRONOUS STORAGE: pass `storage` to the hook, or mount `CoachmarkProvider` with one, as a `CoachmarkStorage` whose `getItem(key)` returns `string | null` and not a Promise. That is a DIFFERENT shape from `<WireOnboarding storage>`, which takes the AsyncStorage-style adapter. Without it the app-open counter is pinned at 1 forever, the fail-closed `minSessions` rule can never be met, and the gate silently never fires; an async adapter is detected and substituted with a fail-closed store, same outcome. The hook's `config` is the GATE's config (it needs an `id`), not the transport config, and `decision` takes `served ?? undefined` because the fetch resolves `ReviewDecisionResponse | null`. `<ReviewGate>` has no `gate` prop: render it on `gate.visible` and pass `onShown={gate.markShown}` + `onResolved={gate.markResolved}`, which are what persist the cooldown and the once-per-version seen gate. Omit them and it compiles, shows, and re-prompts forever. Full wiring: the "Wiring `<ReviewGate>`" block in AGENTS.md.
|
|
35
35
|
- `@wireai/activation/questionnaire`: the pre-onboarding questionnaire gate. `useQuestionnaireGate`, `fetchQuestionnaireDecision` (same server seam; a body without a boolean `fire` resolves to null, never shows).
|
|
36
|
-
- `createRevenueCatBridge({ analytics, entitlementId })` (ROOT-exported) → the RevenueCat purchase funnel: `paywallShown` / `checkoutStarted` / `purchaseCompleted` / `purchaseFailed` / `purchasesRestored` / `syncPlanTier`, emitting the canonical `wire_paywall_shown` / `wire_checkout_started` / `wire_purchase_completed` / `wire_purchase_failed` / `wire_purchase_restored` names. The kit does NOT depend on `react-native-purchases` (a native module); the RevenueCat objects are typed structurally, so a host passes the real ones. THE JOIN KEY between a purchase and an onboarding is `user_context.device_key`, never `session_id` (an onboarding session id is the A2A contextId, an app-event session id is the per-open id, and they are separate spaces): pass the same device key to `createAnalytics`/`createWireActivation` AND to `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`.
|
|
36
|
+
- `createRevenueCatBridge({ analytics, entitlementId })` (ROOT-exported) → the RevenueCat purchase funnel: `paywallShown` / `checkoutStarted` / `purchaseCompleted` / `purchaseFailed` / `purchasesRestored` / `syncPlanTier`, emitting the canonical `wire_paywall_shown` / `wire_checkout_started` / `wire_purchase_completed` / `wire_purchase_failed` / `wire_purchase_restored` names. The kit does NOT depend on `react-native-purchases` (a native module); the RevenueCat objects are typed structurally, so a host passes the real ones. THE JOIN KEY between a purchase and an onboarding is `user_context.device_key`, never `session_id` (an onboarding session id is the A2A contextId, an app-event session id is the per-open id, and they are separate spaces): pass the same device key to `createAnalytics`/`createWireActivation` AND to `<WireOnboarding userContext={activationJoinContext(deviceKey)} ... />`.
|
|
37
37
|
- `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.
|
|
38
38
|
|
|
39
39
|
## Files
|
|
@@ -45,4 +45,4 @@ The package is more than `<WireOnboarding>`. Tree-shakeable subpath exports (eac
|
|
|
45
45
|
|
|
46
46
|
## Fastest path
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
Paste [INTEGRATION_PROMPT.md](./INTEGRATION_PROMPT.md) into a coding agent. It ships in this package and drives the whole integration: detect the app's conventions, wire Metro, derive a theme, scaffold the screen, wire a fallback, and type-check.
|
package/package.json
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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>",
|
|
7
7
|
"license": "MIT",
|
|
8
|
-
"homepage": "https://
|
|
9
|
-
"repository": {
|
|
10
|
-
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/chohra-med/wireai-onboarding.git"
|
|
12
|
-
},
|
|
8
|
+
"homepage": "https://getwireai.com",
|
|
13
9
|
"bugs": {
|
|
14
|
-
"url": "https://
|
|
10
|
+
"url": "https://getwireai.com/console/support",
|
|
11
|
+
"email": "malik@getwireai.com"
|
|
15
12
|
},
|
|
16
13
|
"keywords": [
|
|
17
14
|
"react-native",
|
|
@@ -137,12 +134,12 @@
|
|
|
137
134
|
"scripts": {
|
|
138
135
|
"build": "tsup",
|
|
139
136
|
"prepack": "npm run build",
|
|
140
|
-
"prepublishOnly": "npm run build && npm
|
|
137
|
+
"prepublishOnly": "npm run build && npm test",
|
|
141
138
|
"typecheck": "tsc --noEmit",
|
|
142
139
|
"pretest": "npm run build",
|
|
143
140
|
"test": "node --test --experimental-strip-types --import ./test/register-ts-resolver.mjs \"src/**/*.test.ts\" \"test/canary/**/*.test.tsx\"",
|
|
144
141
|
"test:canary": "node --test --experimental-strip-types --import ./test/register-ts-resolver.mjs \"test/canary/**/*.test.tsx\"",
|
|
145
|
-
"check:contract": "node --no-warnings --experimental-strip-types scripts/check-events-contract.mjs",
|
|
142
|
+
"check:contract": "node --no-warnings --experimental-strip-types --import ./test/register-ts-resolver.mjs scripts/check-events-contract.mjs",
|
|
146
143
|
"pack:tarball": "npm pack"
|
|
147
144
|
},
|
|
148
145
|
"peerDependencies": {
|
package/src/WireOnboarding.tsx
CHANGED
|
@@ -135,7 +135,10 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
135
135
|
// per-install `device_key` the analytics surfaces auto-mint and persist — so the default wiring
|
|
136
136
|
// produces a joined funnel instead of a silent zero. Three conditions, all necessary:
|
|
137
137
|
//
|
|
138
|
-
// • `missingJoinKey` — a host-supplied key ALWAYS wins, verbatim, and is never touched.
|
|
138
|
+
// • `missingJoinKey` — a host-supplied key ALWAYS wins, verbatim, and is never touched. What
|
|
139
|
+
// COUNTS as supplied is `resolveIdentity`'s predicate above: a string with at least one
|
|
140
|
+
// non-whitespace character. A non-string or an all-whitespace `device_key` yields no record,
|
|
141
|
+
// so it reads as absent here and the auto key is merged over it.
|
|
139
142
|
// • `autoJoinKey !== false` — the documented opt-out for a host that genuinely wants an
|
|
140
143
|
// unlinked onboarding session; opting out restores the pre-0.12.2 behavior exactly.
|
|
141
144
|
// • `storage` that actually WORKS — without persistence the auto id is process-scoped, so every
|
|
@@ -77,13 +77,93 @@ type GlobalWithSlot = typeof globalThis & {
|
|
|
77
77
|
|
|
78
78
|
const globalSlot = globalThis as GlobalWithSlot;
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Subscribers to be told when the id above CHANGES.
|
|
82
|
+
*
|
|
83
|
+
* ── WHY A REGISTRY THAT ONLY EMITS IS NOT ENOUGH ─────────────────────────────────────────────────
|
|
84
|
+
* The gates' app-open counter is bumped from a `useState` INITIALIZER, i.e. exactly once per
|
|
85
|
+
* component instance. `bumpSessionCount` itself follows real app-opens correctly, but nothing ever
|
|
86
|
+
* calls it a second time on a screen that stays mounted — and iOS suspends rather than kills, so a
|
|
87
|
+
* home screen holding `useReviewGate` outlives many opens. `useLifecycleEvents` registers each one
|
|
88
|
+
* here and the SERVER's `min_sessions` advances, while `wire_review_<id>_sessions` sits at its launch
|
|
89
|
+
* value and the LOCAL `minSessions` rule is unsatisfiable for the life of the process. Silent, and
|
|
90
|
+
* fails toward a prompt that never shows.
|
|
91
|
+
*
|
|
92
|
+
* The fix is not a second counter: it is that this module — the one place that knows an open was
|
|
93
|
+
* registered — says so. The gates subscribe (through `useSyncExternalStore`) and re-ask
|
|
94
|
+
* `bumpSessionCount`, which was already idempotent per open, so a notification that names the SAME
|
|
95
|
+
* open changes nothing.
|
|
96
|
+
*
|
|
97
|
+
* EVERY write to the id slot notifies, including `ensureCurrentSessionId`'s mint and the test-only
|
|
98
|
+
* reset: `getCurrentSessionId` is the `useSyncExternalStore` snapshot, and a snapshot that can change
|
|
99
|
+
* without a notification is a torn read, not an optimisation.
|
|
100
|
+
*
|
|
101
|
+
* @globalSlot LATCH — the listener SET is created once and its identity is then stable, for the same
|
|
102
|
+
* reason `activation/revalidation`'s store is: a second write replaces the set every existing
|
|
103
|
+
* subscriber registered in, so their unsubscribes free the wrong set and a registration reaches
|
|
104
|
+
* nobody — silently, which is the whole failure class this seam exists to close. Its CONTENTS are
|
|
105
|
+
* live and mutated in place, which is why every accessor goes through `listeners()`.
|
|
106
|
+
*/
|
|
107
|
+
const SESSION_ID_LISTENERS_SLOT: unique symbol = Symbol.for(
|
|
108
|
+
"@wireai/activation:currentSessionIdListeners",
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
type GlobalWithListeners = typeof globalThis & {
|
|
112
|
+
[SESSION_ID_LISTENERS_SLOT]?: Set<() => void>;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const listenerSlot = globalThis as GlobalWithListeners;
|
|
116
|
+
|
|
117
|
+
/** The ONE shared listener set, lazily created on the runtime global (never a second copy per bundle). */
|
|
118
|
+
const listeners = (): Set<() => void> => {
|
|
119
|
+
const existing = listenerSlot[SESSION_ID_LISTENERS_SLOT];
|
|
120
|
+
if (existing) return existing;
|
|
121
|
+
const created = new Set<() => void>();
|
|
122
|
+
listenerSlot[SESSION_ID_LISTENERS_SLOT] = created;
|
|
123
|
+
return created;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Write the slot and, only on a REAL change, tell every subscriber. The single writer for all three
|
|
128
|
+
* public entry points, so "the snapshot moved" and "the subscribers were told" can never drift.
|
|
129
|
+
*/
|
|
130
|
+
const writeCurrentSessionId = (id: string | undefined): void => {
|
|
131
|
+
if (globalSlot[CURRENT_SESSION_ID_SLOT] === id) return;
|
|
132
|
+
globalSlot[CURRENT_SESSION_ID_SLOT] = id;
|
|
133
|
+
// Iterate a snapshot so a listener that (un)subscribes during notify never corrupts the walk, and
|
|
134
|
+
// isolate each one: a throwing subscriber must not stop the others (fail-safe).
|
|
135
|
+
for (const listener of Array.from(listeners())) {
|
|
136
|
+
try {
|
|
137
|
+
listener();
|
|
138
|
+
} catch {
|
|
139
|
+
/* a subscriber's failure is not this registry's business */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Subscribe to app-open registrations. Returns an unsubscribe fn. Shaped for `useSyncExternalStore`
|
|
146
|
+
* over {@link getCurrentSessionId} — that pairing is what makes a mounted gate follow real opens
|
|
147
|
+
* instead of the one it sampled at mount.
|
|
148
|
+
*
|
|
149
|
+
* Internal seam — deliberately NOT on the public barrels (same as {@link isMintedSessionId}); the
|
|
150
|
+
* callers are `useReviewGate` / `useQuestionnaireGate`.
|
|
151
|
+
*/
|
|
152
|
+
export const subscribeCurrentSessionId = (listener: () => void): (() => void) => {
|
|
153
|
+
const set = listeners();
|
|
154
|
+
set.add(listener);
|
|
155
|
+
return () => {
|
|
156
|
+
set.delete(listener);
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
80
160
|
/**
|
|
81
161
|
* Record the current per-open `session_id`. Called by `reportSessionStart` when it emits an
|
|
82
162
|
* app-open. A blank / non-string id is ignored (the previous id stays current). Idempotent.
|
|
83
163
|
*/
|
|
84
164
|
export const setCurrentSessionId = (id: string | undefined): void => {
|
|
85
165
|
if (typeof id === "string" && id.length > 0) {
|
|
86
|
-
|
|
166
|
+
writeCurrentSessionId(id);
|
|
87
167
|
}
|
|
88
168
|
};
|
|
89
169
|
|
|
@@ -91,9 +171,17 @@ export const setCurrentSessionId = (id: string | undefined): void => {
|
|
|
91
171
|
export const getCurrentSessionId = (): string | undefined =>
|
|
92
172
|
globalSlot[CURRENT_SESSION_ID_SLOT];
|
|
93
173
|
|
|
94
|
-
/** Test-only: forget the current session id so a unit test starts from a clean registry.
|
|
174
|
+
/** Test-only: forget the current session id so a unit test starts from a clean registry. Does NOT
|
|
175
|
+
* clear the MINTED-id record (see `isMintedSessionId`), for the same reason the warn latch below is
|
|
176
|
+
* not cleared: `makeSessionId()` never repeats, so a remembered fallback id can never again equal a
|
|
177
|
+
* live one, and the record is overwritten by the next mint anyway. */
|
|
95
178
|
export const resetCurrentSessionId = (): void => {
|
|
96
|
-
|
|
179
|
+
writeCurrentSessionId(undefined);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Test-only: drop every subscriber so one case's mounted gate cannot be notified by the next. */
|
|
183
|
+
export const resetCurrentSessionIdListeners = (): void => {
|
|
184
|
+
listeners().clear();
|
|
97
185
|
};
|
|
98
186
|
|
|
99
187
|
/** The one-time message. Hoisted so a prod mint does not rebuild a string nobody will read. */
|
|
@@ -120,6 +208,52 @@ type GlobalWithWarnSlot = typeof globalThis & { [MINT_WARNED_SLOT]?: boolean };
|
|
|
120
208
|
|
|
121
209
|
const warnSlot = globalThis as GlobalWithWarnSlot;
|
|
122
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The id THIS PROCESS minted, so a reader can tell a fallback id from a real app-open.
|
|
213
|
+
*
|
|
214
|
+
* WHY IT HAS TO EXIST. A mint means "no app-open was registered and something still needed a
|
|
215
|
+
* `session_id`". It is a fallback for the wire, NOT evidence that the app was opened. But the mint
|
|
216
|
+
* writes the shared slot above (it must — see the header: every later event has to correlate to the
|
|
217
|
+
* same id), so a reader of `getCurrentSessionId()` cannot tell the two apart. `reviews/runtime`'s
|
|
218
|
+
* `currentOpenId` could not, and that cost an app-open: it pinned the launch with
|
|
219
|
+
* `observed = <the minted id>`, so its cold-start adopt branch (which keys off `observed ===
|
|
220
|
+
* undefined`) never fired and the REAL `app.session_started` that followed read as a genuinely NEW
|
|
221
|
+
* open. One launch counted as TWO, making the fail-closed `minSessions: 2` default — the guard from
|
|
222
|
+
* the 2026-07-16 one-star incident — satisfiable inside a user's FIRST session. It failed in the
|
|
223
|
+
* UNSAFE direction.
|
|
224
|
+
*
|
|
225
|
+
* Its OWN `Symbol.for` slot, for the same cross-bundle reason as the id itself: a plain module
|
|
226
|
+
* `let` would be per inlined bundle copy, so a mint reached through `./analytics` would be invisible
|
|
227
|
+
* to the gate reading through `.`.
|
|
228
|
+
*
|
|
229
|
+
* @globalSlot LIVE — a real process mints at most once (the mint only fires when the id slot is
|
|
230
|
+
* EMPTY), but a process that clears the id and mints again — `resetCurrentSessionId` in the suite —
|
|
231
|
+
* MOVES this, and it must move: a record still naming the previous launch's fallback would make the
|
|
232
|
+
* new one unrecognisable, and a fallback id would be counted as an app-open again. So a reader that
|
|
233
|
+
* caches this answers
|
|
234
|
+
* for a launch that is over. Read it at the moment of use, through `isMintedSessionId()`.
|
|
235
|
+
*/
|
|
236
|
+
const MINTED_SESSION_ID_SLOT: unique symbol = Symbol.for(
|
|
237
|
+
"@wireai/activation:mintedSessionId",
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
type GlobalWithMintedSlot = typeof globalThis & {
|
|
241
|
+
[MINTED_SESSION_ID_SLOT]?: string | undefined;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const mintedSlot = globalThis as GlobalWithMintedSlot;
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Was `id` MINTED by this process rather than registered as a real app-open?
|
|
248
|
+
*
|
|
249
|
+
* `false` for `undefined`, for any id a host registered through `setCurrentSessionId`, and for
|
|
250
|
+
* every id once the launch has moved on. Internal seam — deliberately NOT on the public barrels;
|
|
251
|
+
* the only caller is `reviews/runtime`'s `currentOpenId`, which must not count a fallback id as an
|
|
252
|
+
* app-open.
|
|
253
|
+
*/
|
|
254
|
+
export const isMintedSessionId = (id: string | undefined): boolean =>
|
|
255
|
+
id !== undefined && id === mintedSlot[MINTED_SESSION_ID_SLOT];
|
|
256
|
+
|
|
123
257
|
/**
|
|
124
258
|
* The current per-open `session_id`, MINTING and registering one when no app-open has been
|
|
125
259
|
* registered yet. Always returns a non-empty string. Idempotent (a second call returns the same id)
|
|
@@ -141,7 +275,10 @@ export const ensureCurrentSessionId = (): string => {
|
|
|
141
275
|
const existing = globalSlot[CURRENT_SESSION_ID_SLOT];
|
|
142
276
|
if (typeof existing === "string" && existing.length > 0) return existing;
|
|
143
277
|
const minted = makeSessionId();
|
|
144
|
-
|
|
278
|
+
writeCurrentSessionId(minted);
|
|
279
|
+
// Remember that WE minted it. A fallback id is not an app-open, and `reviews/runtime` has to be
|
|
280
|
+
// able to tell the difference or the real registration that follows counts as a second open.
|
|
281
|
+
mintedSlot[MINTED_SESSION_ID_SLOT] = minted;
|
|
145
282
|
if (!warnSlot[MINT_WARNED_SLOT] && warnInDev(MINT_WARNING)) {
|
|
146
283
|
warnSlot[MINT_WARNED_SLOT] = true;
|
|
147
284
|
}
|
package/src/analytics/index.ts
CHANGED
|
@@ -44,7 +44,12 @@ export type { ClientEvent, ClientEventType, ClientEventTarget } from "./reportCl
|
|
|
44
44
|
// `readEventsAck` is deliberately NOT re-exported here: the doctor imports it from the transport
|
|
45
45
|
// module directly, so reuse costs nothing and the package's public surface stays as it was.
|
|
46
46
|
export { wireDoctor } from "./wireDoctor";
|
|
47
|
-
export type {
|
|
47
|
+
export type {
|
|
48
|
+
WireDoctorCheck,
|
|
49
|
+
WireDoctorReport,
|
|
50
|
+
WireDoctorOptions,
|
|
51
|
+
WireDoctorJoinTarget,
|
|
52
|
+
} from "./wireDoctor";
|
|
48
53
|
|
|
49
54
|
// ─── Canonical onboarding funnel names + kit-event mapper ──────────────────────
|
|
50
55
|
export { WIRE_ONBOARDING_EVENTS, toAnalyticsEvent } from "./analyticsEvent";
|