@wireai/activation 0.14.3 → 0.15.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 +1 -1
- package/CHANGELOG.md +73 -0
- package/README.md +11 -7
- package/dist/analytics/index.d.mts +5 -5
- package/dist/analytics/index.d.ts +5 -5
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +1 -1
- package/dist/coachmarks/index.d.ts +1 -1
- package/dist/coachmarks/index.js +47 -2
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +47 -2
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-CFSRZ2wg.d.ts} +10 -4
- package/dist/{currentSession-CUvTOchb.d.mts → currentSession-orZy5p1e.d.mts} +10 -4
- 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 +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +3 -3
- package/dist/questionnaire/index.d.ts +3 -3
- package/dist/questionnaire/index.js +48 -3
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +48 -3
- 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 +167 -18
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +167 -18
- 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 +47 -2
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +47 -2
- 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/package.json +1 -1
- package/src/WireOnboarding.tsx +4 -1
- package/src/coachmarks/runtime.ts +103 -2
- package/src/coachmarks/types.ts +7 -2
- package/src/context/deviceId.ts +32 -0
- package/src/reviews/ReviewGate.tsx +86 -37
- package/src/reviews/decision.ts +11 -1
- package/src/reviews/idempotency.ts +71 -0
- package/src/reviews/index.ts +4 -2
- package/src/reviews/runtime.ts +5 -3
- package/src/reviews/transport.ts +17 -12
- package/src/reviews/types.ts +7 -0
- package/src/types.ts +10 -4
- package/src/utils/submitResult.ts +17 -9
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
*
|
|
18
18
|
* The form NEVER asks for identity (no name / email / contact field): the host already
|
|
19
19
|
* identifies the user via `sessionId` + `meta` (e.g. user_id, device_key), which is all
|
|
20
|
-
* the POST body carries alongside the rating and the free text.
|
|
20
|
+
* the POST body carries alongside the rating and the free text. Since 2026-08-19 the gate also
|
|
21
|
+
* fills in `meta.device_key` from the kit's own per-install id when the host passed none — not for
|
|
22
|
+
* identification, but because it is the unit the server scopes the idempotency key to, and without
|
|
23
|
+
* it the key is discarded. Any non-blank string the host passes still wins verbatim. See
|
|
24
|
+
* `idempotency.ts`.
|
|
21
25
|
*
|
|
22
26
|
* Motion (2026-07-16): the three phases used to swap IN PLACE with no transition. They now
|
|
23
27
|
* ride `ModalPaneHost`, the same pane-slide primitive the questionnaire steps use, and the
|
|
@@ -45,6 +49,7 @@ import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
|
45
49
|
import { bodyStyle, captionStyle, headingStyle } from "../theme/typography";
|
|
46
50
|
import { buildReviewSubmission, routeRating } from "./decision";
|
|
47
51
|
import { feedbackInputHeights } from "./feedbackForm";
|
|
52
|
+
import { createImpression, hasDeviceKey, type ReviewImpression } from "./idempotency";
|
|
48
53
|
import { resolvePresentation } from "./presentation";
|
|
49
54
|
import { ReviewModal, type ReviewModalHandle } from "./ReviewModal";
|
|
50
55
|
import { requestStoreReview } from "./storeReview";
|
|
@@ -107,18 +112,63 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
107
112
|
onResolved?.();
|
|
108
113
|
}, [onResolved]);
|
|
109
114
|
|
|
115
|
+
/**
|
|
116
|
+
* ONE identity + ONE idempotency key for this whole gate impression, opened lazily at the first
|
|
117
|
+
* post so a prompt the user never acts on mints nothing.
|
|
118
|
+
*
|
|
119
|
+
* Both values live for the IMPRESSION, not the render and not the post: the server derives the
|
|
120
|
+
* row id from `(app_id, unit, key)`, so a key that moves between two posts writes two rows and a
|
|
121
|
+
* unit that moves does the same. `idempotency.ts` carries the full reasoning.
|
|
122
|
+
*/
|
|
123
|
+
const impressionRef = React.useRef<ReviewImpression | null>(null);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build a submission for THIS impression: the host's `meta` plus whatever the call site adds,
|
|
127
|
+
* stamped with the impression's key and — only when the host supplied none — its device key.
|
|
128
|
+
*
|
|
129
|
+
* A host-supplied `meta.device_key` ALWAYS wins and is never overwritten — "supplied" meaning a
|
|
130
|
+
* string with at least one non-whitespace character, the bar {@link hasDeviceKey} sets; a blank
|
|
131
|
+
* or non-string value is not a usable unit and is replaced. This mirrors the `userContext` /
|
|
132
|
+
* `autoJoinKey` rule one directory over, which sets the same bar through `resolveIdentity`. The
|
|
133
|
+
* injection exists because without a resolvable unit the server discards the key outright, and a
|
|
134
|
+
* home-feed gate has no `session_id` to fall back to.
|
|
135
|
+
*/
|
|
136
|
+
const buildBody = useCallback(
|
|
137
|
+
(input: {
|
|
138
|
+
stars: number;
|
|
139
|
+
feedbackText?: string;
|
|
140
|
+
suggestion?: string;
|
|
141
|
+
extraMeta?: Record<string, unknown>;
|
|
142
|
+
}): ReviewSubmission => {
|
|
143
|
+
let impression = impressionRef.current;
|
|
144
|
+
if (!impression) {
|
|
145
|
+
impression = createImpression();
|
|
146
|
+
impressionRef.current = impression;
|
|
147
|
+
}
|
|
148
|
+
const merged: Record<string, unknown> = { ...meta, ...input.extraMeta };
|
|
149
|
+
if (!hasDeviceKey(merged) && impression.deviceKey) merged.device_key = impression.deviceKey;
|
|
150
|
+
return buildReviewSubmission({
|
|
151
|
+
stars: input.stars,
|
|
152
|
+
feedbackText: input.feedbackText,
|
|
153
|
+
suggestion: input.suggestion,
|
|
154
|
+
sessionId,
|
|
155
|
+
meta: merged,
|
|
156
|
+
idempotencyKey: impression.idempotencyKey,
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
[meta, sessionId],
|
|
160
|
+
);
|
|
161
|
+
|
|
110
162
|
/**
|
|
111
163
|
* EXACTLY ONE review row per gate, ever — the latch that makes "force feedback" safe.
|
|
112
164
|
*
|
|
113
|
-
* WHY ONE POST AND NOT TWO: the tempting design is to post the rating at star-tap and then
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* the failure is invisible to the caller and permanent. Post once, at the last responsible
|
|
121
|
-
* moment, and never depend on an unmerged/undeployed server capability.
|
|
165
|
+
* WHY ONE POST AND NOT TWO: the tempting design is to post the rating at star-tap and then post
|
|
166
|
+
* again with the text, enriching the first row. The server now supports exactly that — a post
|
|
167
|
+
* carrying `idempotency_key` upserts on a derived row id instead of inserting — but the latch
|
|
168
|
+
* stays, because two posts buy nothing the one-at-the-last-responsible-moment post does not
|
|
169
|
+
* already have, and every additional post is another chance to hand the server a unit it cannot
|
|
170
|
+
* resolve. What the key genuinely buys is the RECOVERY paths below: the `unsent` re-post and the
|
|
171
|
+
* abandonment net now merge into the row they may have already written instead of duplicating it.
|
|
122
172
|
*
|
|
123
173
|
* ── THE LATCH IS A CLAIM, NOT A RECEIPT ────────────────────────────────────────────────────
|
|
124
174
|
*
|
|
@@ -134,17 +184,23 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
134
184
|
*
|
|
135
185
|
* "Did it land" and "did the server answer" are DIFFERENT questions, which is why `submitReview`
|
|
136
186
|
* resolves `accepted` / `rejected` / `unsent` instead of a boolean. Only `unsent` un-latches.
|
|
137
|
-
* A non-2xx means the server was REACHED, and
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* cleared: the row is the server's problem now.
|
|
187
|
+
* A non-2xx means the server was REACHED, and we still cannot know whether it stored the row
|
|
188
|
+
* before failing to say so — a gateway 502 after the insert commits looks identical to a refusal.
|
|
189
|
+
* That verdict is left alone DELIBERATELY (2026-08-19): re-posting on it is now safe wherever the
|
|
190
|
+
* key is honoured, but it would also turn a genuine 4xx refusal into a retry needing its own
|
|
191
|
+
* budget, so changing it is its own decision and not this one. On any answer the latch stays
|
|
192
|
+
* closed and `unackedRef` is cleared: the row is the server's problem now.
|
|
143
193
|
*
|
|
144
|
-
*
|
|
145
|
-
* after the row committed throws here exactly like an offline device, so the
|
|
146
|
-
*
|
|
147
|
-
* the server
|
|
194
|
+
* WHAT THE KEY CHANGED, STATED AS A CAPABILITY: `unsent` was never proof the server got nothing —
|
|
195
|
+
* a connection dropped after the row committed throws here exactly like an offline device, so the
|
|
196
|
+
* one re-post below could duplicate. It now carries this impression's `idempotency_key` and the
|
|
197
|
+
* unit the server scopes it to, so the server upserts that row rather than adding a second one.
|
|
198
|
+
* That holds while the unit is resolvable, and a host CANNOT make it unresolvable by editing
|
|
199
|
+
* `meta`: `buildBody` tests the MERGED bucket, so stripping `device_key` there only gets the auto
|
|
200
|
+
* id re-injected. The one post that carries no unit is the one where the kit cannot name an id
|
|
201
|
+
* honestly — two tenants registered in one process leave `impression.deviceKey` undefined, and
|
|
202
|
+
* nothing is stamped. On a gate with no `session_id` that post reaches the server with no unit to
|
|
203
|
+
* scope the key to, so the server ignores the key and it is back in the old, duplicating world.
|
|
148
204
|
*
|
|
149
205
|
* NOTHING HERE IS AWAITED. `postOnce` returns synchronously and every caller advances the phase
|
|
150
206
|
* on the next line, so the user is never waiting on the network to dismiss the prompt.
|
|
@@ -169,8 +225,9 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
169
225
|
postedRef.current = false;
|
|
170
226
|
return;
|
|
171
227
|
}
|
|
172
|
-
// `accepted` OR `rejected`: the server answered, so it owns this row.
|
|
173
|
-
// a non-2xx can follow a committed insert, and
|
|
228
|
+
// `accepted` OR `rejected`: the server answered, so it owns this row. Not re-posted here —
|
|
229
|
+
// a non-2xx can follow a committed insert, and re-posting a refusal is a retry decision
|
|
230
|
+
// with its own budget, deliberately left out of the change that added the key.
|
|
174
231
|
unackedRef.current = null;
|
|
175
232
|
});
|
|
176
233
|
},
|
|
@@ -202,12 +259,11 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
202
259
|
}
|
|
203
260
|
if (postedRef.current || stars < 1) return;
|
|
204
261
|
postOnce(
|
|
205
|
-
|
|
262
|
+
buildBody({
|
|
206
263
|
stars,
|
|
207
264
|
feedbackText: feedback,
|
|
208
265
|
suggestion,
|
|
209
|
-
|
|
210
|
-
meta: { ...meta, abandoned: true },
|
|
266
|
+
extraMeta: { abandoned: true },
|
|
211
267
|
}),
|
|
212
268
|
);
|
|
213
269
|
};
|
|
@@ -249,10 +305,9 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
249
305
|
// the user actually left a review — this only ever means we ASKED. Never read it as
|
|
250
306
|
// a review count. We await first so the row carries the route it actually took.
|
|
251
307
|
postOnce(
|
|
252
|
-
|
|
308
|
+
buildBody({
|
|
253
309
|
stars: value,
|
|
254
|
-
|
|
255
|
-
meta: { ...meta, store_route: route },
|
|
310
|
+
extraMeta: { store_route: route },
|
|
256
311
|
}),
|
|
257
312
|
);
|
|
258
313
|
setPhase("thanks");
|
|
@@ -263,7 +318,7 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
263
318
|
setPhase("feedback");
|
|
264
319
|
}
|
|
265
320
|
},
|
|
266
|
-
[onEvent, id, store, postOnce,
|
|
321
|
+
[onEvent, id, store, postOnce, buildBody, resolveWithDelay],
|
|
267
322
|
);
|
|
268
323
|
|
|
269
324
|
/**
|
|
@@ -284,19 +339,13 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
284
339
|
if (!feedbackReady) return;
|
|
285
340
|
// Identity is NEVER collected here — the host already sends user_id / device /
|
|
286
341
|
// session via `sessionId` + `meta`. We POST only the rating + the free text.
|
|
287
|
-
const body =
|
|
288
|
-
stars,
|
|
289
|
-
feedbackText: feedback,
|
|
290
|
-
suggestion,
|
|
291
|
-
sessionId,
|
|
292
|
-
meta,
|
|
293
|
-
});
|
|
342
|
+
const body = buildBody({ stars, feedbackText: feedback, suggestion });
|
|
294
343
|
postOnce(body);
|
|
295
344
|
onEvent?.({ name: "review_feedback_submitted", id, stars });
|
|
296
345
|
playHaptic("success");
|
|
297
346
|
setPhase("thanks");
|
|
298
347
|
resolveWithDelay();
|
|
299
|
-
}, [feedbackReady, stars, feedback, suggestion,
|
|
348
|
+
}, [feedbackReady, stars, feedback, suggestion, buildBody, postOnce, onEvent, id, resolveWithDelay]);
|
|
300
349
|
|
|
301
350
|
const dismiss = useCallback(() => {
|
|
302
351
|
setPhase("thanks");
|
package/src/reviews/decision.ts
CHANGED
|
@@ -103,7 +103,14 @@ export const evaluateGate = (rules: GateRules, s: GateSignals): ReviewDecision =
|
|
|
103
103
|
export const routeRating = (stars: number): RatingRoute =>
|
|
104
104
|
stars >= 5 ? "store" : "feedback";
|
|
105
105
|
|
|
106
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Build the `POST /v1/reviews` body. Feedback text/contact belong ONLY here, never in events.
|
|
108
|
+
*
|
|
109
|
+
* STILL PURE, deliberately: `idempotencyKey` and the `meta.device_key` the server scopes it to are
|
|
110
|
+
* both passed IN rather than resolved here. Reaching into the device-key registry from this function
|
|
111
|
+
* would make the gate logic untestable in isolation, and both values are scoped to an impression
|
|
112
|
+
* this function cannot see — see `reviews/idempotency.ts`.
|
|
113
|
+
*/
|
|
107
114
|
export const buildReviewSubmission = (input: {
|
|
108
115
|
stars: number;
|
|
109
116
|
feedbackText?: string;
|
|
@@ -111,6 +118,8 @@ export const buildReviewSubmission = (input: {
|
|
|
111
118
|
contact?: string;
|
|
112
119
|
sessionId?: string;
|
|
113
120
|
source?: string;
|
|
121
|
+
/** The impression's idempotency key. Omitted → the server mints a row id and inserts, as before. */
|
|
122
|
+
idempotencyKey?: string;
|
|
114
123
|
meta?: Record<string, unknown>;
|
|
115
124
|
}): ReviewSubmission => {
|
|
116
125
|
const body: ReviewSubmission = { stars: input.stars };
|
|
@@ -122,6 +131,7 @@ export const buildReviewSubmission = (input: {
|
|
|
122
131
|
if (contact) body.contact = contact;
|
|
123
132
|
if (input.sessionId) body.session_id = input.sessionId;
|
|
124
133
|
if (input.source) body.source = input.source;
|
|
134
|
+
if (input.idempotencyKey) body.idempotency_key = input.idempotencyKey;
|
|
125
135
|
if (input.meta && Object.keys(input.meta).length > 0) body.meta = input.meta;
|
|
126
136
|
return body;
|
|
127
137
|
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* idempotency.ts — the ONE idempotency key and the ONE identity a single gate impression posts under.
|
|
3
|
+
*
|
|
4
|
+
* ── WHAT THE SERVER DOES WITH THESE, AND WHY BOTH ARE REQUIRED ───────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* `POST /v1/reviews` accepts an optional `idempotency_key`: two posts carrying the same key upsert
|
|
7
|
+
* ONE row instead of creating two, which is what stops a re-post double-counting `count` and
|
|
8
|
+
* corrupting `avg`. The row id is derived, never the key verbatim —
|
|
9
|
+
* `rev_ + sha256(app_id : unit : key)[:24]` — where `app_id` comes from the resolved `wai_` Bearer
|
|
10
|
+
* key (never the body) and `unit` is the USER the key is scoped to.
|
|
11
|
+
*
|
|
12
|
+
* THE PART THAT MAKES A KEY-ONLY CHANGE INERT: `unit` is `meta.device_key`, else `session_id`, and
|
|
13
|
+
* when the post carries NEITHER the server drops the key on the floor and inserts a normal row —
|
|
14
|
+
* deliberately, because an unbounded key could merge two strangers' reviews. The gate lives on the
|
|
15
|
+
* home feed, where a user legitimately has no onboarding session, so `session_id` is frequently
|
|
16
|
+
* absent; and nothing in the kit ever put a `device_key` into a review `meta` — that was documented
|
|
17
|
+
* as a host responsibility. Sending a key without guaranteeing the identity would therefore have
|
|
18
|
+
* shipped a field the server discards.
|
|
19
|
+
*
|
|
20
|
+
* ── THE SCOPE IS THE WHOLE DESIGN ────────────────────────────────────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* One key per gate IMPRESSION, held across every post of that impression:
|
|
23
|
+
* • minted per POST instead → the re-post carries a different key, writes the second row, and
|
|
24
|
+
* defeats the feature on the exact path it exists to protect.
|
|
25
|
+
* • minted once per INSTALL instead → a later, genuine second review merges into the first and
|
|
26
|
+
* destroys a rating.
|
|
27
|
+
* The identity is pinned for the same reason and in the same object: the auto device key is adopted
|
|
28
|
+
* from persistence ASYNCHRONOUSLY, so a value re-read between two posts of one impression can move
|
|
29
|
+
* under us, and two posts under two different units hash to two different rows.
|
|
30
|
+
*/
|
|
31
|
+
import { ambientAutoDeviceKey } from "../context/deviceId";
|
|
32
|
+
|
|
33
|
+
/** Prefix so a kit-minted review key is visibly ours in a stored `meta` dump. */
|
|
34
|
+
export const REVIEW_IDEMPOTENCY_PREFIX = "rvw_";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Mint a fresh idempotency key. Dependency-free timestamp + randomness, the same scheme
|
|
38
|
+
* `makeSessionId` and `mintDeviceId` already use — the key only has to be unique among the
|
|
39
|
+
* impressions of ONE install, because the server hashes it together with `app_id` and the unit
|
|
40
|
+
* before it ever becomes a row id.
|
|
41
|
+
*/
|
|
42
|
+
export const mintIdempotencyKey = (): string =>
|
|
43
|
+
`${REVIEW_IDEMPOTENCY_PREFIX}${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
44
|
+
|
|
45
|
+
/** Whether a `meta` bucket already carries a usable, host-supplied `device_key`. */
|
|
46
|
+
export const hasDeviceKey = (meta: Record<string, unknown> | undefined): boolean => {
|
|
47
|
+
const value = meta?.device_key;
|
|
48
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The key + identity every post of ONE gate impression carries. Both resolved once, at creation. */
|
|
52
|
+
export interface ReviewImpression {
|
|
53
|
+
/** The `idempotency_key` on the wire. Stable for the impression, distinct across impressions. */
|
|
54
|
+
readonly idempotencyKey: string;
|
|
55
|
+
/**
|
|
56
|
+
* The auto `device_key` to stamp when the host supplied none, or `undefined` when the kit cannot
|
|
57
|
+
* name one honestly (two tenants in one process). A host-supplied key that {@link hasDeviceKey}
|
|
58
|
+
* accepts — a string with at least one non-whitespace character — always wins over this and is
|
|
59
|
+
* never touched; the caller checks that first.
|
|
60
|
+
*/
|
|
61
|
+
readonly deviceKey: string | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Open one impression. Both fields are resolved EAGERLY here rather than read per post, which is the
|
|
66
|
+
* entire point: a lazily re-read key or identity is a per-post value wearing an impression's name.
|
|
67
|
+
*/
|
|
68
|
+
export const createImpression = (): ReviewImpression => ({
|
|
69
|
+
idempotencyKey: mintIdempotencyKey(),
|
|
70
|
+
deviceKey: ambientAutoDeviceKey(),
|
|
71
|
+
});
|
package/src/reviews/index.ts
CHANGED
|
@@ -55,8 +55,10 @@ export { requestStoreReview, openStoreListing, storeUrl } from "./storeReview";
|
|
|
55
55
|
// hand-rolling a decision fetch: a host-rolled one that collapses `{fire:false}` into undefined
|
|
56
56
|
// makes the server able to turn prompts ON but never OFF (the 2026-07-16 first-session incident).
|
|
57
57
|
// `submitReview` resolves with a THREE-state `ReviewSubmitResult` (accepted / rejected / unsent),
|
|
58
|
-
// not a boolean: only `unsent`
|
|
59
|
-
//
|
|
58
|
+
// not a boolean: only `unsent` is retried today. A body built by `ReviewGate` now carries an
|
|
59
|
+
// `idempotency_key` plus the unit the server scopes it to, so that re-post upserts one row instead
|
|
60
|
+
// of writing a second; whether a `rejected` verdict should ALSO retry is a separate recovery-budget
|
|
61
|
+
// decision and is deliberately unchanged.
|
|
60
62
|
export { submitReview, fetchReviewDecision, reportAppEvent } from "./transport";
|
|
61
63
|
export type {
|
|
62
64
|
FetchReviewDecisionOptions,
|
package/src/reviews/runtime.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* mounted CoachmarkProvider gets review gating for free — no second storage to wire.
|
|
6
6
|
*/
|
|
7
7
|
import type { CoachmarkStorage } from "../coachmarks/types";
|
|
8
|
-
import { getCoachmarkStorage } from "../coachmarks/runtime";
|
|
8
|
+
import { getCoachmarkStorage, validateGateStorage } from "../coachmarks/runtime";
|
|
9
9
|
import { getCurrentSessionId } from "../analytics/currentSession";
|
|
10
10
|
import { makeSessionId } from "../analytics/reportClientEvent";
|
|
11
11
|
|
|
@@ -22,10 +22,12 @@ export const reviewSessionsKey = (id: string): string => `wire_review_${id}_sess
|
|
|
22
22
|
/** Companion key holding the open id the counter was LAST incremented for (see {@link bumpSessionCount}). */
|
|
23
23
|
export const reviewSessionOpenKey = (id: string): string => `wire_review_${id}_open`;
|
|
24
24
|
|
|
25
|
-
/** Resolve the storage to use: an explicit override, else the provider-injected singleton
|
|
25
|
+
/** Resolve the storage to use: an explicit override, else the provider-injected singleton — both
|
|
26
|
+
* routed through the thenable probe, so an ASYNC adapter resolves to the fail-closed substitute
|
|
27
|
+
* (see `validateGateStorage` in coachmarks/runtime) instead of feeding Promises to `readInt`. */
|
|
26
28
|
export const resolveStorage = (
|
|
27
29
|
override?: CoachmarkStorage | null,
|
|
28
|
-
): CoachmarkStorage | null => override ?? getCoachmarkStorage();
|
|
30
|
+
): CoachmarkStorage | null => validateGateStorage(override ?? getCoachmarkStorage());
|
|
29
31
|
|
|
30
32
|
/** RN sets this global; absent under node/SSR. Read defensively via {@link warnMissingGateStorage}. */
|
|
31
33
|
declare const __DEV__: boolean | undefined;
|
package/src/reviews/transport.ts
CHANGED
|
@@ -45,23 +45,28 @@ export type ReviewSubmitResult = SubmitResult;
|
|
|
45
45
|
*
|
|
46
46
|
* It was, for exactly one unpublished release, and the boolean was the bug. `false` meant both
|
|
47
47
|
* "nothing reached the server" and "the server answered non-2xx", and the one caller that reads
|
|
48
|
-
* this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
48
|
+
* this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. At the time there was
|
|
49
|
+
* no idempotency key on the wire at all, so a 502 returned AFTER the insert commits meant the
|
|
50
|
+
* re-post wrote a SECOND row, double-counting `count` and corrupting `avg` — the precise corruption
|
|
51
|
+
* the one-row latch exists to prevent. A response of any status proves the server was reached, and
|
|
52
|
+
* that is a different question from whether it liked the row. So the two are different values, and
|
|
53
|
+
* they stay different: the split is what lets the caller decide, and the retry-on-`rejected`
|
|
54
|
+
* question is a recovery-budget decision the key does not answer on its own.
|
|
54
55
|
*
|
|
55
56
|
* ── THE RESIDUAL, STATED HONESTLY ────────────────────────────────────────────────────────────
|
|
56
57
|
*
|
|
57
58
|
* `unsent` is not proof the server never got the row. A connection dropped after the request was
|
|
58
59
|
* written — or after the row committed — surfaces as a thrown/rejected `fetch` here, exactly like
|
|
59
|
-
* an offline device.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
60
|
+
* an offline device. That used to make the `unsent` re-post SAFER rather than SAFE, and it is what
|
|
61
|
+
* the client-minted idempotency key now covers: `ReviewSubmission.idempotency_key` reaches
|
|
62
|
+
* `CreateReviewRequest`, and the server upserts the derived row rather than inserting a second one.
|
|
63
|
+
*
|
|
64
|
+
* IT IS A CAPABILITY, NOT A GUARANTEE, and the condition is not ours to enforce from in here. The
|
|
65
|
+
* server honours the key only when the post also carries a unit it can scope it to —
|
|
66
|
+
* `meta.device_key`, else `session_id` — and ignores it outright otherwise. `ReviewGate` fills the
|
|
67
|
+
* device key in for exactly this reason, but this function posts whatever body it is handed, so a
|
|
68
|
+
* caller that assembles its own body without a unit is back in the old world where a lost detractor
|
|
69
|
+
* is a gap in the data and a duplicated one is a lie in the data.
|
|
65
70
|
*/
|
|
66
71
|
export const submitReview = async (
|
|
67
72
|
target: ReviewTarget | undefined,
|
package/src/reviews/types.ts
CHANGED
|
@@ -97,6 +97,13 @@ export interface ReviewSubmission {
|
|
|
97
97
|
contact?: string;
|
|
98
98
|
session_id?: string;
|
|
99
99
|
source?: string;
|
|
100
|
+
/**
|
|
101
|
+
* OPTIONAL client-minted key for the two-phase submit: two posts carrying the same key upsert ONE
|
|
102
|
+
* row instead of creating two. The server honours it ONLY when the post also carries a resolvable
|
|
103
|
+
* user — `meta.device_key`, else `session_id` — and silently ignores it otherwise, so it always
|
|
104
|
+
* travels with one of those. One key per gate impression, stable across that impression's posts.
|
|
105
|
+
*/
|
|
106
|
+
idempotency_key?: string;
|
|
100
107
|
meta?: Record<string, unknown>;
|
|
101
108
|
}
|
|
102
109
|
|
package/src/types.ts
CHANGED
|
@@ -256,8 +256,11 @@ export type WireOnboardingProps = {
|
|
|
256
256
|
*
|
|
257
257
|
* SINCE 0.12.2, leaving it out no longer silently empties the funnel: when you pass `storage` and
|
|
258
258
|
* this prop carries no `device_key`, the kit injects its OWN per-install key — the same one the
|
|
259
|
-
* analytics surfaces mint and persist — so the default wiring joins.
|
|
260
|
-
* verbatim and is never touched
|
|
259
|
+
* analytics surfaces mint and persist — so the default wiring joins. A `device_key` you DO pass
|
|
260
|
+
* wins verbatim and is never touched, provided it is a string with at least one non-whitespace
|
|
261
|
+
* character (`resolveIdentity`) — this map also accepts numbers and booleans, and a non-string or
|
|
262
|
+
* all-whitespace value is not a usable join key, so it is read as absent and the auto key is
|
|
263
|
+
* merged over it. See `autoJoinKey` for the opt-out and the two cases where the kit
|
|
261
264
|
* still cannot fill the gap (no `storage`, or you opted out), which keep warning in dev.
|
|
262
265
|
*
|
|
263
266
|
* Never hand-write `userContext={{ deviceKey }}`: the server's device lookup reads `device_key`,
|
|
@@ -284,8 +287,11 @@ export type WireOnboardingProps = {
|
|
|
284
287
|
* `autoJoinKey={false}` if you genuinely want an UNLINKED onboarding session; that restores the
|
|
285
288
|
* pre-0.12.2 behavior exactly (nothing injected) and the dev warning fires again.
|
|
286
289
|
*
|
|
287
|
-
* Two things this flag does NOT do. It never overrides a `device_key` you
|
|
288
|
-
* key always wins, whatever this is set to
|
|
290
|
+
* Two things this flag does NOT do. It never overrides a `device_key` you actually supplied — a
|
|
291
|
+
* host-supplied key always wins, whatever this is set to, where "supplied" means a string with at
|
|
292
|
+
* least one non-whitespace character (`resolveIdentity`); a non-string or an all-whitespace value
|
|
293
|
+
* is read as absent and the auto key is merged over it. And it cannot conjure a key without
|
|
294
|
+
* `storage`: with no
|
|
289
295
|
* persistence the kit's id is minted fresh every launch, and a per-launch key corrupts
|
|
290
296
|
* `min_sessions` instead of merely leaving the join empty, so the kit declines to inject and warns
|
|
291
297
|
* in dev instead.
|
|
@@ -21,19 +21,27 @@
|
|
|
21
21
|
*
|
|
22
22
|
* `submitReview` was a boolean for exactly one unpublished release, and the boolean WAS the bug.
|
|
23
23
|
* `false` meant both "nothing reached the server" and "the server answered non-2xx", and the caller
|
|
24
|
-
* treats `false` as "still owed" and re-posts.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
24
|
+
* treats `false` as "still owed" and re-posts. At the time neither body carried a client id, so
|
|
25
|
+
* there was NO idempotency key on the wire on either surface: a 502 returned AFTER the insert
|
|
26
|
+
* commits meant the re-post wrote a SECOND row. A response of any status proves the server was
|
|
27
|
+
* reached, and that is a different question from whether it liked the payload — which is why the
|
|
28
|
+
* three values stay, on both surfaces, whatever the wire gains.
|
|
29
29
|
*
|
|
30
30
|
* ── THE RESIDUAL, STATED HONESTLY ─────────────────────────────────────────────────────────────
|
|
31
31
|
*
|
|
32
32
|
* `unsent` is not proof the server never got it. A connection dropped after the request was written
|
|
33
33
|
* — or after the row committed — surfaces as a thrown/rejected `fetch`, exactly like an offline
|
|
34
|
-
* device.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
34
|
+
* device. Closing that window needs a client-minted idempotency key the server upserts on, and AS
|
|
35
|
+
* OF 2026-08-19 THE TWO SURFACES DIFFER — do not read this paragraph as one fact about both:
|
|
36
|
+
*
|
|
37
|
+
* • REVIEWS — closed. `CreateReviewRequest` takes an optional `idempotency_key` and the server
|
|
38
|
+
* upserts a derived row on it, so a `ReviewGate` re-post merges instead of duplicating. It is a
|
|
39
|
+
* capability, not a guarantee: the server honours the key only when the post also carries a
|
|
40
|
+
* unit to scope it to (`meta.device_key`, else `session_id`), so a caller that strips both is
|
|
41
|
+
* back to the old behaviour.
|
|
42
|
+
* • QUESTIONNAIRES — still open, and still exactly as this paragraph used to read for both. The
|
|
43
|
+
* response body carries no client id and the server has no idempotency path for it at all, so
|
|
44
|
+
* retrying only on `unsent` there is SAFER, not SAFE. Prefer losing a row over inventing one: a
|
|
45
|
+
* lost detractor is a gap in the data, a duplicated one is a lie in the data.
|
|
38
46
|
*/
|
|
39
47
|
export type SubmitResult = "accepted" | "rejected" | "unsent";
|