@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
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
* ── WHY A NON-DURABLE UNIT IS DELIBERATELY ACCEPTED HERE (ruled 0.15.1) ──────────────────────
|
|
69
|
+
*
|
|
70
|
+
* The kit's standing identity rule is that a caller writing a cross-launch join key must read
|
|
71
|
+
* `IdentityRecord.durable` and REFUSE a `false` — see `context/deviceId.ts`. That rule does not
|
|
72
|
+
* transfer to this field, and an audit that ports it here makes the data WORSE, so the reasoning is
|
|
73
|
+
* recorded rather than left to be rediscovered:
|
|
74
|
+
*
|
|
75
|
+
* • WHAT THE RULE PROTECTS elsewhere is a COUNTER. `app.session_started` is counted by distinct
|
|
76
|
+
* opens grouped on `device_key`, so a per-launch key there makes `min_sessions` structurally
|
|
77
|
+
* incapable of exceeding 1 — the key actively corrupts the metric. A review row is written once
|
|
78
|
+
* per user, so a per-launch value here cannot corrupt a count the same way.
|
|
79
|
+
* • WHAT REFUSING WOULD COST is the feature itself. With no unit the server's
|
|
80
|
+
* `_idempotency_unit` returns nothing, it DISCARDS the key and inserts a plain row — so the
|
|
81
|
+
* `unsent` re-post and the abandonment net each write a SECOND review row, double-counting
|
|
82
|
+
* `count` and corrupting `avg`. That is the exact corruption the key was added to prevent, and
|
|
83
|
+
* it is a corruption traded for an under-count, which is the wrong direction.
|
|
84
|
+
* • THE SCOPE THE UNIT ACTUALLY NEEDS is one impression inside one launch, and that is precisely
|
|
85
|
+
* what a pinned in-memory value guarantees. Durability buys nothing the server ever reads here.
|
|
86
|
+
* • THE ORPHANED-JOIN WORRY (a review row stamped with a key no analytics event carries) is real
|
|
87
|
+
* and is NOT closed by any of the above — it is an ID-SPACE problem, not a durability one, and
|
|
88
|
+
* it survives here. Two sequences still produce it, both of them ordinary:
|
|
89
|
+
* (1) the impression opens BEFORE any surface has registered a real `appId`, so it pins the
|
|
90
|
+
* ambient fallback while every later analytics event carries the host's id. This is the
|
|
91
|
+
* same cold-start ordering the ambient reader was fixed for, and the test that covers
|
|
92
|
+
* that fix calls it the sequence rather than an edge case.
|
|
93
|
+
* (2) the impression opens while a hydration is IN FLIGHT, so it pins the synchronous mint
|
|
94
|
+
* and the persisted id replaces it in the registry a moment later.
|
|
95
|
+
* What WAS closed is a different and larger failure: the ambient reader used to answer
|
|
96
|
+
* `undefined` permanently once the real `appId` registered, which cost every later impression
|
|
97
|
+
* its unit outright. The orphan that remains is bounded to impressions opened before the
|
|
98
|
+
* process's id space settles, and the gate is once-per-user, so in practice it is at most the
|
|
99
|
+
* first row.
|
|
100
|
+
* • AND THE ORPHAN IS NOT AN ARGUMENT FOR REFUSING, which is the trap. Dropping the unit does not
|
|
101
|
+
* repair the join — it removes it AND removes the server's ability to dedupe, so an orphaned
|
|
102
|
+
* row that is at least idempotent becomes a row that is neither. Closing it properly means
|
|
103
|
+
* giving the gate a settled identity to pin, which is a public-API change (`appId` / `storage`
|
|
104
|
+
* props on `ReviewGate`) and is deliberately not attempted here.
|
|
105
|
+
*/
|
|
106
|
+
export const createImpression = (): ReviewImpression => ({
|
|
107
|
+
idempotencyKey: mintIdempotencyKey(),
|
|
108
|
+
deviceKey: ambientAutoDeviceKey(),
|
|
109
|
+
});
|
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,8 +5,8 @@
|
|
|
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";
|
|
9
|
-
import { getCurrentSessionId } from "../analytics/currentSession";
|
|
8
|
+
import { getCoachmarkStorage, validateGateStorage } from "../coachmarks/runtime";
|
|
9
|
+
import { getCurrentSessionId, isMintedSessionId } from "../analytics/currentSession";
|
|
10
10
|
import { makeSessionId } from "../analytics/reportClientEvent";
|
|
11
11
|
|
|
12
12
|
/** Once-gate key. Keyed by app version when `oncePerVersion` is on, so a new release re-enables. */
|
|
@@ -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;
|
|
@@ -154,12 +156,26 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
|
|
|
154
156
|
// that was live when the pin was taken, and read it lazily.
|
|
155
157
|
//
|
|
156
158
|
// • pin unset → pin = the registered id ?? a freshly minted one, and remember which
|
|
157
|
-
// registered id that was (`undefined` when we had to mint)
|
|
158
|
-
//
|
|
159
|
-
//
|
|
159
|
+
// registered id that was (`undefined` when we had to mint) and whether
|
|
160
|
+
// it was itself a MINTED fallback.
|
|
161
|
+
// • remembered `undefined`
|
|
162
|
+
// or remembered MINTED → the FIRST REAL registration merely NAMES the open the gate already
|
|
163
|
+
// counted under a fallback id. Adopt it, leave the PIN alone. This is
|
|
160
164
|
// the cold-start case, and it stays shut.
|
|
161
165
|
// • live id ≠ remembered → a genuinely new app-open. Re-pin to it; the counter bumps once.
|
|
162
166
|
//
|
|
167
|
+
// ── A MINTED ID IS NOT AN APP-OPEN (the second half of the same rule) ────────────────────────
|
|
168
|
+
// The bullet above says "remembered MINTED" because the registry has TWO writers, not one.
|
|
169
|
+
// `reportSessionStart` registers a real open — but `ensureCurrentSessionId()` also WRITES the shared
|
|
170
|
+
// slot, with a fallback id it minted so a fire-and-forget event is not dropped for want of a
|
|
171
|
+
// `session_id`, and it is reached from `analyticsFacade.resolveSessionId`, `wireActivation.track`
|
|
172
|
+
// and `reviews/transport.reportAppEvent`. A host can hit any of those before `useLifecycleEvents`
|
|
173
|
+
// mounts. That made the pin's `observed` a minted id rather than `undefined`, so the cold-start
|
|
174
|
+
// branch never fired and the real `app.session_started` that followed fell through to the last
|
|
175
|
+
// branch as "a genuinely new app-open": ONE launch counted as TWO, and `minSessions: 2` became
|
|
176
|
+
// satisfiable inside a user's FIRST session — failing in the UNSAFE direction. `isMintedSessionId`
|
|
177
|
+
// is the seam that tells the two writers apart; a fallback is not evidence the app was opened.
|
|
178
|
+
//
|
|
163
179
|
// ⚠️ ONE ACCEPTED MISS, deliberately in the safe direction: if the mount open never registers (a
|
|
164
180
|
// refused non-durable device key over a broken store) and a LATER foreground is the first
|
|
165
181
|
// registration the launch ever sees, that registration is ADOPTED rather than counted, costing one
|
|
@@ -182,6 +198,12 @@ type OpenPin = {
|
|
|
182
198
|
openId: string;
|
|
183
199
|
/** The registered session id observed at pin time; `undefined` when `openId` was minted. */
|
|
184
200
|
observed: string | undefined;
|
|
201
|
+
/**
|
|
202
|
+
* True when `observed` was a FALLBACK id `ensureCurrentSessionId` minted, not a real app-open.
|
|
203
|
+
* Such a pin has to behave exactly like `observed === undefined`: nothing has named this open yet,
|
|
204
|
+
* so the first REAL registration adopts it rather than starting a second one.
|
|
205
|
+
*/
|
|
206
|
+
observedMinted: boolean;
|
|
185
207
|
};
|
|
186
208
|
|
|
187
209
|
type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: OpenPin };
|
|
@@ -196,28 +218,37 @@ const openIdGlobal = globalThis as GlobalWithOpenId;
|
|
|
196
218
|
*/
|
|
197
219
|
export const currentOpenId = (): string => {
|
|
198
220
|
const live = getCurrentSessionId();
|
|
221
|
+
// A FALLBACK id is not an app-open (see the note above `OpenPin.observedMinted`), so it is
|
|
222
|
+
// recorded as such and never treated as a registration this pin could be measured against.
|
|
223
|
+
const liveMinted = isMintedSessionId(live);
|
|
199
224
|
const pin = openIdGlobal[PROCESS_OPEN_ID_SLOT];
|
|
200
225
|
|
|
201
226
|
// First read of the launch. Adopt the registered id when the host wired lifecycle BEFORE any gate
|
|
202
227
|
// rendered — that is the same unit the server counts — otherwise mint one and record that we did.
|
|
203
228
|
if (!pin) {
|
|
204
229
|
const openId = live ?? makeSessionId();
|
|
205
|
-
openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live };
|
|
230
|
+
openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live, observedMinted: liveMinted };
|
|
206
231
|
return openId;
|
|
207
232
|
}
|
|
208
233
|
|
|
209
234
|
// Nothing registered yet, or still the same registered open: the pin stands.
|
|
210
235
|
if (!live || live === pin.observed) return pin.openId;
|
|
211
236
|
|
|
212
|
-
// The first registration of the launch NAMES the open we already pinned
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
237
|
+
// The first REAL registration of the launch NAMES the open we already pinned — whether we pinned
|
|
238
|
+
// under an id this function minted (`observed === undefined`) or under one `ensureCurrentSessionId`
|
|
239
|
+
// minted for an early event (`observedMinted`). Either way nothing had named the open yet, so it is
|
|
240
|
+
// adopted, not counted. Re-pinning here is exactly the cold-start double-count described above.
|
|
241
|
+
if (pin.observed === undefined || pin.observedMinted) {
|
|
242
|
+
openIdGlobal[PROCESS_OPEN_ID_SLOT] = {
|
|
243
|
+
openId: pin.openId,
|
|
244
|
+
observed: live,
|
|
245
|
+
observedMinted: liveMinted,
|
|
246
|
+
};
|
|
216
247
|
return pin.openId;
|
|
217
248
|
}
|
|
218
249
|
|
|
219
250
|
// A second, DIFFERENT registration is a genuinely new app-open.
|
|
220
|
-
openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live };
|
|
251
|
+
openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live, observedMinted: liveMinted };
|
|
221
252
|
return live;
|
|
222
253
|
};
|
|
223
254
|
|
package/src/reviews/transport.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { ensureCurrentSessionId } from "../analytics/currentSession";
|
|
18
18
|
import { buildEventsRequest, type ClientEvent } from "../analytics/reportClientEvent";
|
|
19
|
+
import { DEADLINE_EXPIRED, withDeadline } from "../utils/withDeadline";
|
|
19
20
|
import type { SubmitResult } from "../utils/submitResult";
|
|
20
21
|
import type { ReviewDecisionResponse, ReviewSubmission, ReviewTarget } from "./types";
|
|
21
22
|
|
|
@@ -45,23 +46,28 @@ export type ReviewSubmitResult = SubmitResult;
|
|
|
45
46
|
*
|
|
46
47
|
* It was, for exactly one unpublished release, and the boolean was the bug. `false` meant both
|
|
47
48
|
* "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
|
-
*
|
|
49
|
+
* this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. At the time there was
|
|
50
|
+
* no idempotency key on the wire at all, so a 502 returned AFTER the insert commits meant the
|
|
51
|
+
* re-post wrote a SECOND row, double-counting `count` and corrupting `avg` — the precise corruption
|
|
52
|
+
* the one-row latch exists to prevent. A response of any status proves the server was reached, and
|
|
53
|
+
* that is a different question from whether it liked the row. So the two are different values, and
|
|
54
|
+
* they stay different: the split is what lets the caller decide, and the retry-on-`rejected`
|
|
55
|
+
* question is a recovery-budget decision the key does not answer on its own.
|
|
54
56
|
*
|
|
55
57
|
* ── THE RESIDUAL, STATED HONESTLY ────────────────────────────────────────────────────────────
|
|
56
58
|
*
|
|
57
59
|
* `unsent` is not proof the server never got the row. A connection dropped after the request was
|
|
58
60
|
* written — or after the row committed — surfaces as a thrown/rejected `fetch` here, exactly like
|
|
59
|
-
* an offline device.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
61
|
+
* an offline device. That used to make the `unsent` re-post SAFER rather than SAFE, and it is what
|
|
62
|
+
* the client-minted idempotency key now covers: `ReviewSubmission.idempotency_key` reaches
|
|
63
|
+
* `CreateReviewRequest`, and the server upserts the derived row rather than inserting a second one.
|
|
64
|
+
*
|
|
65
|
+
* IT IS A CAPABILITY, NOT A GUARANTEE, and the condition is not ours to enforce from in here. The
|
|
66
|
+
* server honours the key only when the post also carries a unit it can scope it to —
|
|
67
|
+
* `meta.device_key`, else `session_id` — and ignores it outright otherwise. `ReviewGate` fills the
|
|
68
|
+
* device key in for exactly this reason, but this function posts whatever body it is handed, so a
|
|
69
|
+
* caller that assembles its own body without a unit is back in the old world where a lost detractor
|
|
70
|
+
* is a gap in the data and a duplicated one is a lie in the data.
|
|
65
71
|
*/
|
|
66
72
|
export const submitReview = async (
|
|
67
73
|
target: ReviewTarget | undefined,
|
|
@@ -72,11 +78,19 @@ export const submitReview = async (
|
|
|
72
78
|
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/reviews`;
|
|
73
79
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
74
80
|
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
// Under a deadline (see `utils/withDeadline`): a hung POST used to hold `ReviewGate`'s
|
|
82
|
+
// `postOnce` latch closed for the ~60s platform default, which is exactly the window the
|
|
83
|
+
// unmount recovery net cannot re-post inside.
|
|
84
|
+
const res = await withDeadline((signal) =>
|
|
85
|
+
fetch(url, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers,
|
|
88
|
+
body: JSON.stringify(review),
|
|
89
|
+
signal,
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
// A deadline that expired is not a rejection: nothing reached the server, so it stays re-postable.
|
|
93
|
+
if (res === DEADLINE_EXPIRED) return "unsent";
|
|
80
94
|
// No response object at all is not an answer — treat it as nothing having reached the server
|
|
81
95
|
// rather than as a rejection, or a stubbed-out `fetch` would silently latch the row away.
|
|
82
96
|
if (!res) return "unsent";
|
|
@@ -188,9 +202,14 @@ export const fetchReviewDecision = async (
|
|
|
188
202
|
const url = `${base}/v1/reviews/decision${qs ? `?${qs}` : ""}`;
|
|
189
203
|
const headers: Record<string, string> = {};
|
|
190
204
|
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const json =
|
|
205
|
+
// The BODY read is inside the deadline too: a ceiling cleared when the headers land is not a
|
|
206
|
+
// ceiling (the `fetchWireFeatures` lesson — see `utils/withDeadline`).
|
|
207
|
+
const json = await withDeadline(async (signal): Promise<ReviewDecisionResponse | null> => {
|
|
208
|
+
const res = await fetch(url, { headers, signal });
|
|
209
|
+
if (!res || !res.ok) return null;
|
|
210
|
+
return (await res.json()) as ReviewDecisionResponse | null;
|
|
211
|
+
});
|
|
212
|
+
if (json === DEADLINE_EXPIRED) return null;
|
|
194
213
|
// A body without a boolean `fire` is not a decision. Guard it explicitly rather than
|
|
195
214
|
// letting `{}` through as a truthy object that `decideReview` would treat as a verdict
|
|
196
215
|
// (`{}.fire === undefined` is falsy, so it would silently read as "never fire").
|
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
|
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
* (`wire_review_<id>_seen`) always applies locally, so a server bug can never spam the prompt.
|
|
13
13
|
* The global `isTestingCoachmark` flag force-shows the gate for QA replay.
|
|
14
14
|
*/
|
|
15
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
15
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
16
16
|
|
|
17
|
+
import { getCurrentSessionId, subscribeCurrentSessionId } from "../analytics/currentSession";
|
|
17
18
|
import { hasSeenGate, isCoachmarkTesting, markSeenGate } from "../coachmarks/runtime";
|
|
18
|
-
import {
|
|
19
|
+
import { useResolvedFeaturesState } from "../features/WireFeaturesProvider";
|
|
19
20
|
import { decideReview, evaluateGate, resolveRules } from "./decision";
|
|
20
21
|
import { sameDecision, shallowEqual } from "./equality";
|
|
21
22
|
import {
|
|
@@ -67,7 +68,19 @@ export const useReviewGate = ({
|
|
|
67
68
|
|
|
68
69
|
// The review MASTER switch. Disabled → the gate never fires, composing as an extra AND over
|
|
69
70
|
// the local rules + the server decision seam below (fail-open: defaults to enabled).
|
|
70
|
-
|
|
71
|
+
//
|
|
72
|
+
// `settled` is read too, and it is the half that makes the switch real. The flags start on the
|
|
73
|
+
// all-on defaults and swap only when the fetch resolves, so `featureEnabled` is `true` on the
|
|
74
|
+
// FIRST render of every mount — including a tenant with `review.enabled: false`. With no
|
|
75
|
+
// `timeoutFallbackMs` the local wait is skipped as well (`elapsed` starts `true`), so the gate
|
|
76
|
+
// could become visible, fire `review_prompt_shown`, and take a POSTed row from a user the tenant
|
|
77
|
+
// had switched off. The comment below ("must never see the gate") was falsified by its own async
|
|
78
|
+
// default. Holding the FIRST impression until an answer exists is what honours the switch.
|
|
79
|
+
const { flags: resolvedFeatures, settled: featuresSettled } = useResolvedFeaturesState({
|
|
80
|
+
flags: features,
|
|
81
|
+
config: featuresConfig,
|
|
82
|
+
});
|
|
83
|
+
const featureEnabled = resolvedFeatures.review.enabled;
|
|
71
84
|
|
|
72
85
|
const testing = isTesting ?? isCoachmarkTesting();
|
|
73
86
|
const seenKey = reviewSeenKey(config.id, config.oncePerVersion === false ? undefined : config.appVersion);
|
|
@@ -75,19 +88,51 @@ export const useReviewGate = ({
|
|
|
75
88
|
const sessionsKey = reviewSessionsKey(config.id);
|
|
76
89
|
const sessionOpenKey = reviewSessionOpenKey(config.id);
|
|
77
90
|
|
|
91
|
+
// ── FOLLOW real app-opens, do not sample one ────────────────────────────────────────────────
|
|
92
|
+
//
|
|
93
|
+
// `bumpSessionCount` is IDEMPOTENT per app-open and moves once per open — but it used to be reached
|
|
94
|
+
// ONLY from the `useState` initializer below, which runs once per component INSTANCE. On a screen
|
|
95
|
+
// that never unmounts (a home feed, a tab that stays alive) that meant once per PROCESS, and iOS
|
|
96
|
+
// suspends rather than kills: `useLifecycleEvents` fires a fresh `app.session_started` on every
|
|
97
|
+
// foreground past its threshold and the SERVER's `min_sessions` advances, while `wire_review_<id>_sessions`
|
|
98
|
+
// stayed at its launch value and the LOCAL `minSessions` rule was unsatisfiable for the life of the
|
|
99
|
+
// app. Silent, and in the safe direction (a prompt that never shows), which is why it survived —
|
|
100
|
+
// `sessionCountAcrossOpens.test.ts` proved the FUNCTION advances and could not see that the HOOK
|
|
101
|
+
// never asked it again.
|
|
102
|
+
//
|
|
103
|
+
// So subscribe to the registry that knows. A notification naming the same open is a no-op by
|
|
104
|
+
// construction (`bumpSessionCount` reads its stored count back), so the cold-start ordering this
|
|
105
|
+
// pin exists for is untouched.
|
|
106
|
+
const openSessionId = useSyncExternalStore(
|
|
107
|
+
subscribeCurrentSessionId,
|
|
108
|
+
getCurrentSessionId,
|
|
109
|
+
getCurrentSessionId,
|
|
110
|
+
);
|
|
111
|
+
// Read through a ref: a host that passes `storage={{ … }}` inline (idiomatic React, and what
|
|
112
|
+
// `frequent_rules` #11 requires this hook to tolerate) mints a fresh identity every render, and
|
|
113
|
+
// listing it in the deps below would re-run the effect on every one of them.
|
|
114
|
+
const storageRef = useRef(storage);
|
|
115
|
+
storageRef.current = storage;
|
|
116
|
+
|
|
78
117
|
// Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount: `bumpSessionCount`
|
|
79
118
|
// keys off the live per-open session id (or a per-process id when no host wired the lifecycle
|
|
80
119
|
// events), so a remount, a navigation return, or React StrictMode's dev double-invoke of this
|
|
81
120
|
// initializer all read the same number back instead of inflating it. Before this, `sessions`
|
|
82
121
|
// counted mounts, so the fail-closed `minSessions: 2` default could be satisfied inside the user's
|
|
83
122
|
// very first app open — the exact scenario it was added to prevent.
|
|
84
|
-
const sessions = useState(() => {
|
|
123
|
+
const [sessions, setSessions] = useState(() => {
|
|
85
124
|
const store = resolveStorage(storage);
|
|
86
125
|
// No storage pins the counter at 1 forever, so the fail-closed minSessions rule can never be
|
|
87
126
|
// met and the gate silently never fires. Dev-only, once per process.
|
|
88
127
|
warnMissingGateStorage(store, "review");
|
|
89
128
|
return bumpSessionCount(store, sessionsKey, sessionOpenKey);
|
|
90
|
-
})
|
|
129
|
+
});
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const next = bumpSessionCount(resolveStorage(storageRef.current), sessionsKey, sessionOpenKey);
|
|
132
|
+
// A bail-out when the count is unchanged, so a notification for an open already counted costs
|
|
133
|
+
// nothing: React skips the re-render when the state is identical.
|
|
134
|
+
setSessions((prev) => (prev === next ? prev : next));
|
|
135
|
+
}, [openSessionId, sessionsKey, sessionOpenKey]);
|
|
91
136
|
|
|
92
137
|
// Gate the local rules behind an optional client-side timeout, so a reachable server
|
|
93
138
|
// gets a window to answer first. A present `decision` bypasses the wait entirely.
|
|
@@ -123,10 +168,15 @@ export const useReviewGate = ({
|
|
|
123
168
|
const resolved = decideReview(local, decision);
|
|
124
169
|
// (3) the once-gate always wins locally, even over a server "fire".
|
|
125
170
|
const alreadySeen = hasSeenGate(seenKey, storage, isTesting);
|
|
126
|
-
|
|
171
|
+
// FAIL-OPEN IS NOT WEAKENED BY THIS. `featuresSettled` is `false` only while a fetch that could
|
|
172
|
+
// still change the answer is in flight; it is `true` with no `featuresConfig` (nothing to ask),
|
|
173
|
+
// with explicit `features`, and — the case that matters — the moment a FAILED fetch resolves,
|
|
174
|
+
// because `fetchWireFeatures` swallows a timeout / 401 / 5xx into the all-on defaults. So an
|
|
175
|
+
// unreachable control plane still allows the gate; only an unanswered one holds it.
|
|
176
|
+
const ready = (hasServerDecision || elapsed) && featuresSettled;
|
|
127
177
|
return { visible: ready && resolved.fire && !alreadySeen, verdict: resolved };
|
|
128
178
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
129
|
-
}, [featureEnabled, testing, config, decision, events, sessions, elapsed, hasServerDecision, seenKey, lastKey, storage, isTesting]);
|
|
179
|
+
}, [featureEnabled, featuresSettled, testing, config, decision, events, sessions, elapsed, hasServerDecision, seenKey, lastKey, storage, isTesting]);
|
|
130
180
|
|
|
131
181
|
// Stable callbacks + a stable controller object, so a host can safely list any of
|
|
132
182
|
// them in its own effect deps without the effect re-firing every render.
|
|
@@ -173,8 +173,24 @@ const emitFirstOpen = (opts: ReportFirstOpenOptions): void => {
|
|
|
173
173
|
* • Race guard: an in-memory latch is set SYNCHRONOUSLY before the async read, so two
|
|
174
174
|
* near-simultaneous calls fire at most once.
|
|
175
175
|
* • Without `storage`: degraded mode — fires once per PROCESS via the latch only (documented).
|
|
176
|
+
* • WITHOUT A TRANSPORT: no-op, and — the point of the check — the once-ever flag is NOT spent.
|
|
177
|
+
* See below.
|
|
176
178
|
*/
|
|
177
179
|
export const reportFirstOpen = (opts: ReportFirstOpenOptions): void => {
|
|
180
|
+
// NO TRANSPORT, NO SPEND. `emitFirstOpen` routes through `buildEventsRequest`, which returns
|
|
181
|
+
// `null` when there is no `target.serverUrl` — so with neither a `sink` nor a server URL the emit
|
|
182
|
+
// is a silent no-op. Both once-ever guards were still consumed underneath it: the in-memory latch
|
|
183
|
+
// synchronously, and then the PERSISTED `wireai:first_open:<appId>` flag on the write below. That
|
|
184
|
+
// marks the install as having reported its first open when NOTHING was ever sent, and the flag
|
|
185
|
+
// survives app kills — so `app.first_open` was dead for that install FOREVER, with no error and
|
|
186
|
+
// no event. Reachable on the documented wiring: a `LifecycleConfig` carrying `storage` + `appId`
|
|
187
|
+
// whose `serverUrl` is optional and resolves late (the hook's effect is mount-only).
|
|
188
|
+
//
|
|
189
|
+
// The sibling emitter already gets this right — `reportSessionStart` bails on
|
|
190
|
+
// `!opts.sink && !target?.serverUrl` BEFORE its own once-per-open guard — and this is the same
|
|
191
|
+
// check in the same position, for the same reason. A guard may only be spent by a real send.
|
|
192
|
+
if (!opts.sink && !opts.target?.serverUrl) return;
|
|
193
|
+
|
|
178
194
|
const appId = opts.appId ?? "default";
|
|
179
195
|
|
|
180
196
|
// Synchronous latch FIRST: guarantees at-most-one before any await (same-process race guard) and
|
|
@@ -36,7 +36,12 @@ import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../anal
|
|
|
36
36
|
import { hydrateDeviceIdentity } from "../context/deviceId";
|
|
37
37
|
import { resolveIdentity } from "../identity/identityRecord";
|
|
38
38
|
import { collectDeviceContext } from "../device/deviceContext";
|
|
39
|
-
import
|
|
39
|
+
import {
|
|
40
|
+
READ_TIMED_OUT,
|
|
41
|
+
READ_TIMEOUT_MS,
|
|
42
|
+
withTimeout,
|
|
43
|
+
type WireOnboardingStorage,
|
|
44
|
+
} from "../session/persistedSession";
|
|
40
45
|
import { reportFirstOpen } from "./lifecycle";
|
|
41
46
|
import { reportSessionStart } from "./reportSessionStart";
|
|
42
47
|
import { BACKGROUND_SESSION_MS } from "./useSessionStart";
|
|
@@ -173,6 +178,23 @@ export const useLifecycleEvents = (
|
|
|
173
178
|
* emits the event with NO auto key — the same verdict `<WireOnboarding>` reaches on a broken
|
|
174
179
|
* adapter, and strictly better than a key that differs on every launch. Never rejects: the read
|
|
175
180
|
* resolves to a record or `undefined`, never a throw.
|
|
181
|
+
*
|
|
182
|
+
* ── AND IT IS CAPPED, BECAUSE "NEVER REJECTS" IS NOT "ALWAYS SETTLES" ──────────────────────
|
|
183
|
+
*
|
|
184
|
+
* `hydrateDeviceIdentity` awaits a BARE `storage.getItem`, so an adapter that HANGS (the read
|
|
185
|
+
* neither resolves nor rejects — a locked keychain, a wedged native bridge) leaves the promise
|
|
186
|
+
* pending forever. Every fire in this hook lives inside that `.then`, so a hung adapter did not
|
|
187
|
+
* degrade the device key: it silently killed `app.session_started` AND `app.first_open` for the
|
|
188
|
+
* whole process, on every launch. Those two events are the funnel's denominator and what the
|
|
189
|
+
* server counts `min_sessions` from, so `min_sessions` stays 0 and every review / questionnaire
|
|
190
|
+
* trigger becomes unsatisfiable — with nothing logged and nothing thrown.
|
|
191
|
+
*
|
|
192
|
+
* The read is therefore raced against the SAME ceiling the rest of the kit reads through
|
|
193
|
+
* (`session/persistedSession`'s exported `withTimeout` + `READ_TIMEOUT_MS`, which is also the
|
|
194
|
+
* ceiling `<WireOnboarding>`'s auto-join gate names). Blowing it fires with NO auto key —
|
|
195
|
+
* byte-identical to the refusal a rejecting adapter already gets — because a read that did not
|
|
196
|
+
* answer cannot prove the id is durable, and an event with no device key is a recoverable gap
|
|
197
|
+
* while no event at all is a permanent one.
|
|
176
198
|
*/
|
|
177
199
|
const openAutoDeviceKey = (fire: (autoDeviceKey: string | undefined) => void): void => {
|
|
178
200
|
const { config: cfg, options: opts } = latest.current;
|
|
@@ -182,8 +204,14 @@ export const useLifecycleEvents = (
|
|
|
182
204
|
fire(undefined);
|
|
183
205
|
return;
|
|
184
206
|
}
|
|
185
|
-
void
|
|
207
|
+
void withTimeout(
|
|
208
|
+
hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }),
|
|
209
|
+
READ_TIMEOUT_MS,
|
|
210
|
+
).then((read) => {
|
|
186
211
|
if (cancelled) return;
|
|
212
|
+
// READ_TIMED_OUT = the adapter never answered in time. It collapses onto the SAME branch as
|
|
213
|
+
// a non-durable record — fire with no auto key — so the events still go out, degraded.
|
|
214
|
+
const identity = read === READ_TIMED_OUT ? undefined : read;
|
|
187
215
|
fire(identity?.durable ? identity.value : undefined);
|
|
188
216
|
});
|
|
189
217
|
};
|
|
@@ -31,7 +31,12 @@ import { AppState, Platform, type AppStateStatus } from "react-native";
|
|
|
31
31
|
import type { ClientEventTarget } from "../analytics/reportClientEvent";
|
|
32
32
|
import { hydrateDeviceIdentity } from "../context/deviceId";
|
|
33
33
|
import { collectDeviceContext } from "../device/deviceContext";
|
|
34
|
-
import
|
|
34
|
+
import {
|
|
35
|
+
READ_TIMED_OUT,
|
|
36
|
+
READ_TIMEOUT_MS,
|
|
37
|
+
withTimeout,
|
|
38
|
+
type WireOnboardingStorage,
|
|
39
|
+
} from "../session/persistedSession";
|
|
35
40
|
import { reportSessionStart } from "./reportSessionStart";
|
|
36
41
|
|
|
37
42
|
/** A foreground after at least this long in the background counts as a NEW app-open (30 min). */
|
|
@@ -125,6 +130,15 @@ export const useSessionStart = (
|
|
|
125
130
|
* rule `context/deviceId.ts` states for exactly these callers: *"Callers that write a key onto the
|
|
126
131
|
* wire as a cross-launch join must read `durable` and refuse a `false`."* A host-supplied key or a
|
|
127
132
|
* config with no `storage` still fires synchronously — there is nothing to read.
|
|
133
|
+
*
|
|
134
|
+
* AND THE WAIT IS CAPPED. `hydrateDeviceIdentity` awaits a BARE `storage.getItem`, so an adapter
|
|
135
|
+
* that HANGS (neither resolves nor rejects) leaves that promise pending forever — and since the
|
|
136
|
+
* only fire path in this hook lives inside the `.then`, `app.session_started` then never fires at
|
|
137
|
+
* all, for the whole process, on every launch. That is the event the server counts `min_sessions`
|
|
138
|
+
* from, so the counter stays 0 and every server-side trigger becomes unsatisfiable, silently. The
|
|
139
|
+
* read is raced against the kit's shared storage ceiling (`session/persistedSession`'s exported
|
|
140
|
+
* `withTimeout` + `READ_TIMEOUT_MS`, the same one `<WireOnboarding>`'s auto-join gate names), and
|
|
141
|
+
* blowing it fires with NO auto key — exactly the branch a rejecting adapter already takes.
|
|
128
142
|
*/
|
|
129
143
|
const openAutoDeviceKey = (fireOpen: (autoDeviceKey: string | undefined) => void): void => {
|
|
130
144
|
const { config: cfg, options: opts } = latest.current;
|
|
@@ -134,8 +148,14 @@ export const useSessionStart = (
|
|
|
134
148
|
fireOpen(undefined);
|
|
135
149
|
return;
|
|
136
150
|
}
|
|
137
|
-
void
|
|
151
|
+
void withTimeout(
|
|
152
|
+
hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }),
|
|
153
|
+
READ_TIMEOUT_MS,
|
|
154
|
+
).then((read) => {
|
|
138
155
|
if (cancelled) return;
|
|
156
|
+
// READ_TIMED_OUT = the adapter never answered in time. Same branch as a non-durable record:
|
|
157
|
+
// fire with no auto key, so the app-open is still counted.
|
|
158
|
+
const identity = read === READ_TIMED_OUT ? undefined : read;
|
|
139
159
|
fireOpen(identity?.durable ? identity.value : undefined);
|
|
140
160
|
});
|
|
141
161
|
};
|