@wireai/activation 0.13.2 → 0.13.5
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 +6 -3
- package/CHANGELOG.md +291 -4
- package/INTEGRATION_PROMPT.md +7 -1
- package/README.md +68 -2
- package/dist/analytics/index.d.mts +86 -6
- package/dist/analytics/index.d.ts +86 -6
- package/dist/analytics/index.js +271 -472
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +269 -471
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +15 -2
- package/dist/coachmarks/index.d.ts +15 -2
- package/dist/coachmarks/index.js +58 -14
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +58 -14
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-ClkLjcJ0.d.mts → currentSession-DgJf0fRz.d.mts} +4 -4
- package/dist/{currentSession-DOVZEWJl.d.ts → currentSession-EOHU64QD.d.ts} +4 -4
- package/dist/{decision-Bl_M2y3r.d.mts → decision-Bgo17oH7.d.mts} +1 -1
- package/dist/{decision-Cau5KmP6.d.ts → decision-Bkh_LigV.d.ts} +1 -1
- package/dist/index.d.mts +27 -4
- package/dist/index.d.ts +27 -4
- package/dist/index.js +224 -575
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +221 -572
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +43 -11
- package/dist/questionnaire/index.d.ts +43 -11
- package/dist/questionnaire/index.js +45 -13
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +45 -13
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +5 -5
- package/dist/reviews/index.d.ts +5 -5
- package/dist/reviews/index.js +31 -9
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +31 -9
- 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 +7 -2
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +7 -2
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/{transport-CF_eHwzC.d.mts → transport-B_0SgCBe.d.ts} +44 -5
- package/dist/{transport-DsRe4epC.d.ts → transport-j5gFfJhK.d.mts} +44 -5
- package/dist/{types-CKFhyrMu.d.mts → types-BpwiRpA8.d.mts} +1 -1
- package/dist/{types-CKFhyrMu.d.ts → types-BpwiRpA8.d.ts} +1 -1
- package/dist/{types-CNUqMK0D.d.mts → types-Cju-1_jT.d.mts} +42 -2
- package/dist/{types-Buj9Lw9t.d.ts → types-h2BZvl1t.d.ts} +42 -2
- package/llms.txt +1 -1
- package/package.json +6 -1
- package/src/OnboardingFlow.tsx +44 -15
- package/src/WireOnboarding.tsx +7 -6
- package/src/activation/revalidation.ts +9 -1
- package/src/activation/wireActivation.ts +3 -3
- package/src/analytics/analyticsFacade.ts +2 -2
- package/src/analytics/currentSession.ts +10 -0
- package/src/analytics/eventQueue.ts +88 -20
- package/src/analytics/index.ts +6 -0
- package/src/analytics/reportClientEvent.ts +29 -7
- package/src/analytics/wireDoctor.ts +330 -0
- package/src/cards/CardGridSelectCard.tsx +1 -1
- package/src/cards/ChipSelectCard.tsx +2 -2
- package/src/cards/SelectionCard.tsx +1 -1
- package/src/coachmarks/runtime.ts +72 -3
- package/src/coachmarks/useCoachmarkTour.ts +62 -14
- package/src/context/deviceId.ts +14 -5
- package/src/features/WireFeaturesProvider.tsx +12 -1
- package/src/identity/identityRecord.ts +9 -1
- package/src/identity/userIdentity.ts +1 -1
- package/src/questionnaire/QuestionnaireGate.tsx +90 -10
- package/src/questionnaire/index.ts +9 -1
- package/src/questionnaire/transport.ts +46 -11
- package/src/reviews/ReviewGate.tsx +61 -6
- package/src/reviews/index.ts +10 -1
- package/src/reviews/runtime.ts +11 -1
- package/src/reviews/transport.ts +58 -12
- package/src/session-analytics/useLifecycleEvents.ts +1 -1
- package/src/theme/ThemeContext.tsx +49 -1
- package/src/types.ts +1 -1
- package/src/utils/submitResult.ts +39 -0
|
@@ -65,7 +65,15 @@ export type ResolveIdentityInput = {
|
|
|
65
65
|
scope?: string;
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Well-known key into the runtime-global symbol registry — one provenance registry per process.
|
|
70
|
+
*
|
|
71
|
+
* @globalSlot LATCH — the REGISTRY OBJECT is created once and its identity is then stable. A second
|
|
72
|
+
* write drops every host id recorded so far, so `hostIdentity()` answers "no host key in this
|
|
73
|
+
* process" for a process that demonstrably has one, and the kit injects its own `wdev_*` beside it
|
|
74
|
+
* without warning. Its CONTENTS are live (`host` gains an entry on every host-sourced
|
|
75
|
+
* `resolveIdentity`), so both accessors go through `provenanceRegistry()` on every call.
|
|
76
|
+
*/
|
|
69
77
|
const IDENTITY_PROVENANCE_SLOT: unique symbol = Symbol.for("@wireai/activation:identityProvenance");
|
|
70
78
|
|
|
71
79
|
/** `"<space>:<scope>"` → the HOST-supplied value seen for it. Auto values are never recorded. */
|
|
@@ -131,7 +131,7 @@ export const identifyOnboarding = async (
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
let space: Exclude<IdentifyOnboardingBinding, false> = "onboarding";
|
|
134
|
-
// The OPT-IN last resort
|
|
134
|
+
// The OPT-IN last resort. Until 0.13.0 this ran unconditionally: with no captured contextId
|
|
135
135
|
// it posted the LIVE PER-OPEN session id in the `session_id` field — which on this endpoint means
|
|
136
136
|
// the ONBOARDING session — and then returned `true`. Two disjoint id spaces share that field, so
|
|
137
137
|
// the row it wrote could never join the onboarding funnel, and the host got a success signal for a
|
|
@@ -27,8 +27,10 @@
|
|
|
27
27
|
* step is simply an empty answer, which is what the builder already handled.
|
|
28
28
|
*
|
|
29
29
|
* ⚠️ This gate now REQUIRES the server to accept an empty `answers.opinion` - step 1 is
|
|
30
|
-
* skippable and the live endpoint still 422s a blank opinion
|
|
31
|
-
*
|
|
30
|
+
* skippable and the live endpoint still 422s a blank opinion. The 422 is read (the transport
|
|
31
|
+
* resolves `"rejected"`) but deliberately not retried — the server ANSWERED, so re-posting could
|
|
32
|
+
* duplicate the row. The user still sees the thank-you either way. See the REQUIRED_STEPS note in
|
|
33
|
+
* steps.ts. Server fix, not a client one.
|
|
32
34
|
*
|
|
33
35
|
* Motion: `ModalPaneHost` slides step→step (and mirrors the slide on Back) and resizes the
|
|
34
36
|
* card; `StepDots` fills; `DoneCheck` plays the submit payoff. All shared, all reduce-motion
|
|
@@ -69,7 +71,7 @@ import {
|
|
|
69
71
|
type StepDrafts,
|
|
70
72
|
} from "./steps";
|
|
71
73
|
import { submitQuestionnaireResponse } from "./transport";
|
|
72
|
-
import type { QuestionnaireGateProps } from "./types";
|
|
74
|
+
import type { QuestionnaireGateProps, QuestionnaireSubmission } from "./types";
|
|
73
75
|
|
|
74
76
|
type Phase = "form" | "thanks";
|
|
75
77
|
|
|
@@ -193,16 +195,94 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
|
|
|
193
195
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
194
196
|
}, [id, onResolved]);
|
|
195
197
|
|
|
198
|
+
/**
|
|
199
|
+
* EXACTLY ONE response row per run, ever — the twin of `postOnce` in `reviews/ReviewGate.tsx`,
|
|
200
|
+
* and the reason an answer given offline is no longer silently thrown away.
|
|
201
|
+
*
|
|
202
|
+
* ── THE DEFECT THIS CLOSES ─────────────────────────────────────────────────────────────────
|
|
203
|
+
*
|
|
204
|
+
* `finish` used to call a `void`-returning `submitQuestionnaireResponse` and move on. The
|
|
205
|
+
* response was never read, so a submission that died in the socket was indistinguishable from
|
|
206
|
+
* one the server stored — and `onResolved` → `markResolved` writes the PERMANENT
|
|
207
|
+
* `wire_questionnaire_<id>_<ver>_seen` key regardless. A user who answered offline lost their
|
|
208
|
+
* answers AND was never asked again. This is the same hole 0.13.3 closed for reviews, on the one
|
|
209
|
+
* surface that both burns a permanent once-gate and had zero evidence of delivery.
|
|
210
|
+
*
|
|
211
|
+
* ── THE LATCH IS A CLAIM, NOT A RECEIPT ────────────────────────────────────────────────────
|
|
212
|
+
*
|
|
213
|
+
* `postedRef` is taken OPTIMISTICALLY, before the row has landed, so two callers in the same tick
|
|
214
|
+
* cannot post twice. It is RELEASED only when the transport reports that NOTHING REACHED THE
|
|
215
|
+
* SERVER, and the undelivered body is kept in `unackedRef` so the unmount net below can re-post
|
|
216
|
+
* it when the gate goes away.
|
|
217
|
+
*
|
|
218
|
+
* ── A NON-2xx IS NOT A RETRY TICKET ────────────────────────────────────────────────────────
|
|
219
|
+
*
|
|
220
|
+
* Only `"unsent"` un-latches. A non-2xx means the server was REACHED, and the response id is
|
|
221
|
+
* server-minted with no idempotency key on the wire, so a 502 arriving after the insert commits
|
|
222
|
+
* looks identical to a refusal. Re-posting on that verdict writes a SECOND response row — the
|
|
223
|
+
* duplicate-row defect reviews already fixed, one module over. On any answer the latch stays
|
|
224
|
+
* closed and `unackedRef` is cleared.
|
|
225
|
+
*
|
|
226
|
+
* NOTHING HERE IS AWAITED. `postOnce` returns synchronously and `finish` advances to the
|
|
227
|
+
* thank-you on the next line, so the user never waits on the network.
|
|
228
|
+
*
|
|
229
|
+
* HONEST CEILING — identical to reviews, deliberately: recovery is IN-PROCESS, one re-post when
|
|
230
|
+
* the gate unmounts. A run answered offline and never recovered before the app is killed is still
|
|
231
|
+
* lost, and the once-gate is still written by the host on `onResolved`. Durable cross-launch
|
|
232
|
+
* retry needs a storage prop on this component (new public API) or a generalized queue (the
|
|
233
|
+
* analytics one is hardwired to POST /v1/events, and a questionnaire response is not an event);
|
|
234
|
+
* both are deliberately out of scope here.
|
|
235
|
+
*/
|
|
236
|
+
const postedRef = useRef(false);
|
|
237
|
+
/** The last submission fired with no answer yet. Non-null after an UNSENT post = still owed. */
|
|
238
|
+
const unackedRef = useRef<QuestionnaireSubmission | null>(null);
|
|
239
|
+
const postOnce = useCallback(
|
|
240
|
+
(body: QuestionnaireSubmission) => {
|
|
241
|
+
if (postedRef.current) return;
|
|
242
|
+
postedRef.current = true;
|
|
243
|
+
unackedRef.current = body;
|
|
244
|
+
void submitQuestionnaireResponse(target, id, body).then((result) => {
|
|
245
|
+
if (result === "unsent") {
|
|
246
|
+
// Nothing reached the server → un-latch, so these answers can still go out.
|
|
247
|
+
postedRef.current = false;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// `accepted` OR `rejected`: the server answered, so it owns this row. Never re-post it.
|
|
251
|
+
unackedRef.current = null;
|
|
252
|
+
});
|
|
253
|
+
},
|
|
254
|
+
[target, id],
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The recovery point for a submission the server never ANSWERED — never for one it answered
|
|
259
|
+
* non-2xx, which `postOnce` clears rather than re-queues. Read through a ref and fired from an
|
|
260
|
+
* unmount-only effect, so it always sees the latest state without re-subscribing (the same ref
|
|
261
|
+
* convention the fire-once effect above uses).
|
|
262
|
+
*
|
|
263
|
+
* Unlike `ReviewGate` there is no abandonment branch here: a questionnaire that was never
|
|
264
|
+
* submitted has no payload to rescue (a half-typed draft is not an answer, and the last step is
|
|
265
|
+
* required precisely so a completed run always carries one). This net exists for exactly one
|
|
266
|
+
* case — the run FINISHED and the answers never made it onto the wire.
|
|
267
|
+
*/
|
|
268
|
+
const recoverRef = useRef<() => void>(() => {});
|
|
269
|
+
recoverRef.current = () => {
|
|
270
|
+
const undelivered = unackedRef.current;
|
|
271
|
+
if (undelivered && !postedRef.current) postOnce(undelivered);
|
|
272
|
+
};
|
|
273
|
+
React.useEffect(() => () => recoverRef.current(), []);
|
|
274
|
+
|
|
196
275
|
// The run is over: POST the answers and play the payoff. Identity is NEVER collected here -
|
|
197
276
|
// the host already sends user_id / device_key / session via `sessionId` + `meta`.
|
|
198
277
|
const finish = useCallback(
|
|
199
278
|
(drafts: StepDrafts) => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
279
|
+
postOnce(
|
|
280
|
+
buildQuestionnaireSubmission({
|
|
281
|
+
answers: toAnswers(drafts),
|
|
282
|
+
sessionId,
|
|
283
|
+
meta,
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
206
286
|
submittedRef.current = true;
|
|
207
287
|
onEvent?.({ name: "questionnaire_submitted", id });
|
|
208
288
|
playHaptic("success");
|
|
@@ -212,7 +292,7 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
|
|
|
212
292
|
modalRef.current?.close();
|
|
213
293
|
}, 1500);
|
|
214
294
|
},
|
|
215
|
-
[sessionId, meta,
|
|
295
|
+
[sessionId, meta, postOnce, id, onEvent],
|
|
216
296
|
);
|
|
217
297
|
|
|
218
298
|
// Apply a machine transition: either submit (last step) or seed the next step's draft.
|
|
@@ -28,8 +28,16 @@ export {
|
|
|
28
28
|
export type { GateSignals, GateRules } from "./decision";
|
|
29
29
|
|
|
30
30
|
// ─── Server transport: submit a response + best-effort fetch the decision ───────
|
|
31
|
+
// `submitQuestionnaireResponse` resolves a THREE-state `QuestionnaireSubmitResult`
|
|
32
|
+
// (accepted / rejected / unsent), not `void`: only `unsent` may be retried, because the wire has no
|
|
33
|
+
// idempotency key and a re-post after a non-2xx that already committed writes a second row. It is
|
|
34
|
+
// still non-throwing and still fired synchronously — the caller never awaits.
|
|
31
35
|
export { submitQuestionnaireResponse, fetchQuestionnaireDecision } from "./transport";
|
|
32
|
-
export type {
|
|
36
|
+
export type {
|
|
37
|
+
FetchQuestionnaireDecisionOptions,
|
|
38
|
+
QuestionnaireSubmitResult,
|
|
39
|
+
} from "./transport";
|
|
40
|
+
export type { SubmitResult } from "../utils/submitResult";
|
|
33
41
|
|
|
34
42
|
// ─── Gate-key helpers (shared coachmark storage singleton) ──────────────────────
|
|
35
43
|
export {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* transport.ts - kit → Wire server requests for the questionnaire module,
|
|
2
|
+
* transport.ts - kit → Wire server requests for the questionnaire module, never throwing
|
|
3
3
|
* (a pre-onboarding questionnaire must NEVER break the app). Mirrors `submitReview`: a thin
|
|
4
|
-
* fetch wrapper, Bearer tenant key,
|
|
4
|
+
* fetch wrapper, Bearer tenant key, and — since 0.13.3 — the SAME three-outcome delivery ack.
|
|
5
5
|
*
|
|
6
6
|
* • submitQuestionnaireResponse → POST {serverUrl}/v1/questionnaires/{id}/responses
|
|
7
7
|
* • fetchQuestionnaireDecision → GET {serverUrl}/v1/questionnaires/decision (best-effort)
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* non-2xx (incl. 404), a missing `fetch`, or bad JSON all resolve to null, so a host wiring
|
|
11
11
|
* pure server-directed firing simply shows nothing - exactly the reviews decision seam.
|
|
12
12
|
*/
|
|
13
|
+
import type { SubmitResult } from "../utils/submitResult";
|
|
13
14
|
import type {
|
|
14
15
|
QuestionnaireDecisionResponse,
|
|
15
16
|
QuestionnaireSubmission,
|
|
@@ -17,29 +18,63 @@ import type {
|
|
|
17
18
|
} from "./types";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
21
|
+
* What became of a questionnaire POST. The SHARED verdict — identical values, identical meaning,
|
|
22
|
+
* identical retry rule as `ReviewSubmitResult`. The reasoning lives once, in `utils/submitResult.ts`.
|
|
22
23
|
*/
|
|
23
|
-
export
|
|
24
|
+
export type QuestionnaireSubmitResult = SubmitResult;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* POST a questionnaire response.
|
|
28
|
+
*
|
|
29
|
+
* NON-BLOCKING, and ACKED. It never throws and never makes a caller wait — the request is fired
|
|
30
|
+
* synchronously and the UI is free to advance on the next line — but it RESOLVES with what became
|
|
31
|
+
* of the response.
|
|
32
|
+
*
|
|
33
|
+
* ── WHY THE RETURN VALUE EXISTS (this used to be `void`) ─────────────────────────────────────
|
|
34
|
+
*
|
|
35
|
+
* The body was `void fetch(...).catch(() => {})`: the response was never read, so the caller could
|
|
36
|
+
* not tell a delivered submission from one that died in the socket. `QuestionnaireGate.finish`
|
|
37
|
+
* therefore latched the run as submitted unconditionally and resolved the gate, and `onResolved` →
|
|
38
|
+
* `markResolved` writes the PERMANENT `wire_questionnaire_<id>_<ver>_seen` key. A user who answered
|
|
39
|
+
* offline was never asked again and their answers were gone — the exact failure 0.13.3's
|
|
40
|
+
* `submitReview` fix was written for ("a detractor who rated offline was dropped AND never asked
|
|
41
|
+
* again"), on the one surface that both burns a permanent once-gate and had zero evidence of
|
|
42
|
+
* delivery. This is the twin of that fix.
|
|
43
|
+
*
|
|
44
|
+
* ── ONLY `unsent` MAY BE RETRIED ─────────────────────────────────────────────────────────────
|
|
45
|
+
*
|
|
46
|
+
* The same rule as reviews, for the same reason: the server mints the response id and the body
|
|
47
|
+
* carries no client id, so there is no idempotency key on the wire. A non-2xx proves the server was
|
|
48
|
+
* REACHED — a gateway 502 after the insert commits is indistinguishable from a refusal — so
|
|
49
|
+
* re-posting on it writes a SECOND response row. See `utils/submitResult.ts` for the full split and
|
|
50
|
+
* the honest residual.
|
|
51
|
+
*
|
|
52
|
+
* A missing target or a missing id resolves `"unsent"` with no request at all: nothing reached a
|
|
53
|
+
* server, which is exactly what a caller must be told before it decides the answers are delivered.
|
|
54
|
+
*/
|
|
55
|
+
export const submitQuestionnaireResponse = async (
|
|
24
56
|
target: QuestionnaireTarget | undefined,
|
|
25
57
|
id: string,
|
|
26
58
|
submission: QuestionnaireSubmission,
|
|
27
|
-
):
|
|
28
|
-
if (!target?.serverUrl || !id) return;
|
|
59
|
+
): Promise<QuestionnaireSubmitResult> => {
|
|
60
|
+
if (!target?.serverUrl || !id) return "unsent";
|
|
29
61
|
try {
|
|
30
62
|
const base = target.serverUrl.replace(/\/$/, "");
|
|
31
63
|
const url = `${base}/v1/questionnaires/${encodeURIComponent(id)}/responses`;
|
|
32
64
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
33
65
|
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
34
|
-
|
|
66
|
+
const res = await fetch(url, {
|
|
35
67
|
method: "POST",
|
|
36
68
|
headers,
|
|
37
69
|
body: JSON.stringify(submission),
|
|
38
|
-
}).catch(() => {
|
|
39
|
-
/* best-effort, swallow */
|
|
40
70
|
});
|
|
71
|
+
// No response object at all is not an answer — treat it as nothing having reached the server
|
|
72
|
+
// rather than as a rejection, or a stubbed-out `fetch` would silently latch the answers away.
|
|
73
|
+
if (!res) return "unsent";
|
|
74
|
+
return (res as { ok?: boolean }).ok ? "accepted" : "rejected";
|
|
41
75
|
} catch {
|
|
42
|
-
/* URL/JSON/missing-fetch -
|
|
76
|
+
/* URL/JSON/missing-fetch/network - nothing came back, and never surfaced to the UI */
|
|
77
|
+
return "unsent";
|
|
43
78
|
}
|
|
44
79
|
};
|
|
45
80
|
|
|
@@ -115,18 +115,64 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
115
115
|
* client-supplied id so the second write UPSERTS. It does not, as deployed today:
|
|
116
116
|
* `create_review` mints its own id unconditionally (`app/reviews.py` `_new_id()`) and
|
|
117
117
|
* `CreateReviewRequest` (`app/schemas.py`) has no id field — so two posts = two rows, which
|
|
118
|
-
* double-counts `count` and corrupts `avg`.
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
118
|
+
* double-counts `count` and corrupts `avg`. Reading the ack does not rescue that guess either:
|
|
119
|
+
* a second post against a server with no upsert comes back 2xx HAVING created the second row, so
|
|
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.
|
|
122
|
+
*
|
|
123
|
+
* ── THE LATCH IS A CLAIM, NOT A RECEIPT ────────────────────────────────────────────────────
|
|
124
|
+
*
|
|
125
|
+
* The latch is taken OPTIMISTICALLY, before the row has landed, because its first job is to stop
|
|
126
|
+
* two callers in the same tick from posting twice (the double-row corruption above). It is
|
|
127
|
+
* RELEASED again when `submitReview` reports that NOTHING REACHED THE SERVER, and the undelivered
|
|
128
|
+
* body is kept in `unackedRef` so the abandonment net below can re-post it when the gate goes
|
|
129
|
+
* away. Without that release, a rating that died in the socket — the offline detractor, whose 1-4
|
|
130
|
+
* stars plus mandatory text is the most valuable payload this kit carries — is latched as posted
|
|
131
|
+
* and silently dropped, and the once-gate means they are never asked again.
|
|
132
|
+
*
|
|
133
|
+
* ── A NON-2xx IS NOT A RETRY TICKET ────────────────────────────────────────────────────────
|
|
134
|
+
*
|
|
135
|
+
* "Did it land" and "did the server answer" are DIFFERENT questions, which is why `submitReview`
|
|
136
|
+
* resolves `accepted` / `rejected` / `unsent` instead of a boolean. Only `unsent` un-latches.
|
|
137
|
+
* A non-2xx means the server was REACHED, and with no idempotency key on the wire (see above:
|
|
138
|
+
* the id is server-minted) we cannot know whether it stored the row before failing to say so — a
|
|
139
|
+
* gateway 502 after the insert commits looks identical to a refusal. Re-posting on that verdict
|
|
140
|
+
* writes the second row this whole latch exists to prevent, and does it on the exact code path
|
|
141
|
+
* meant to protect the data. So on any answer the latch stays closed and `unackedRef` is
|
|
142
|
+
* cleared: the row is the server's problem now.
|
|
143
|
+
*
|
|
144
|
+
* HONEST RESIDUAL: `unsent` is not proof the server never got it either. A connection dropped
|
|
145
|
+
* after the row committed throws here exactly like an offline device, so the one re-post below
|
|
146
|
+
* can still duplicate. This is SAFER, not safe. The real fix is a client-minted idempotency key
|
|
147
|
+
* the server upserts on — a server change, out of scope for the kit.
|
|
148
|
+
*
|
|
149
|
+
* NOTHING HERE IS AWAITED. `postOnce` returns synchronously and every caller advances the phase
|
|
150
|
+
* on the next line, so the user is never waiting on the network to dismiss the prompt.
|
|
151
|
+
*
|
|
152
|
+
* HONEST CEILING: recovery is IN-PROCESS — one re-post when the gate unmounts. A rating taken
|
|
153
|
+
* offline and never recovered before the app is killed is still lost. Durable cross-launch retry
|
|
154
|
+
* needs either a storage prop on this component (new public API) or a generalized
|
|
155
|
+
* `createEventQueue` (that queue is hardwired to POST /v1/events, and a review row is not an
|
|
156
|
+
* event); both are deliberately out of scope here.
|
|
123
157
|
*/
|
|
124
158
|
const postedRef = React.useRef(false);
|
|
159
|
+
/** The last submission fired with no answer yet. Non-null after an UNSENT post = still owed. */
|
|
160
|
+
const unackedRef = React.useRef<ReviewSubmission | null>(null);
|
|
125
161
|
const postOnce = useCallback(
|
|
126
162
|
(body: ReviewSubmission) => {
|
|
127
163
|
if (postedRef.current) return;
|
|
128
164
|
postedRef.current = true;
|
|
129
|
-
|
|
165
|
+
unackedRef.current = body;
|
|
166
|
+
void submitReview(target, body).then((result) => {
|
|
167
|
+
if (result === "unsent") {
|
|
168
|
+
// Nothing reached the server → un-latch, so this row can still go out.
|
|
169
|
+
postedRef.current = false;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// `accepted` OR `rejected`: the server answered, so it owns this row. Never re-post it —
|
|
173
|
+
// a non-2xx can follow a committed insert, and the wire has no idempotency key.
|
|
174
|
+
unackedRef.current = null;
|
|
175
|
+
});
|
|
130
176
|
},
|
|
131
177
|
[target],
|
|
132
178
|
);
|
|
@@ -142,9 +188,18 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
142
188
|
*
|
|
143
189
|
* Read through a ref and fired from an unmount-only effect, so it always sees the latest
|
|
144
190
|
* state without re-subscribing (the same ref convention `review_prompt_shown` above uses).
|
|
191
|
+
*
|
|
192
|
+
* It is ALSO the recovery point for a submission the server never ANSWERED — never for one it
|
|
193
|
+
* answered non-2xx, which `postOnce` clears rather than re-queues. That body already carries the
|
|
194
|
+
* user's real text, so it is re-posted verbatim rather than re-flagged `abandoned`.
|
|
145
195
|
*/
|
|
146
196
|
const abandonRef = React.useRef<() => void>(() => {});
|
|
147
197
|
abandonRef.current = () => {
|
|
198
|
+
const undelivered = unackedRef.current;
|
|
199
|
+
if (undelivered && !postedRef.current) {
|
|
200
|
+
postOnce(undelivered);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
148
203
|
if (postedRef.current || stars < 1) return;
|
|
149
204
|
postOnce(
|
|
150
205
|
buildReviewSubmission({
|
package/src/reviews/index.ts
CHANGED
|
@@ -54,8 +54,17 @@ export { requestStoreReview, openStoreListing, storeUrl } from "./storeReview";
|
|
|
54
54
|
// `fetchReviewDecision` is the mirror of `fetchQuestionnaireDecision`. Use it instead of
|
|
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
|
+
// `submitReview` resolves with a THREE-state `ReviewSubmitResult` (accepted / rejected / unsent),
|
|
58
|
+
// not a boolean: only `unsent` may be retried, because the wire has no idempotency key and a
|
|
59
|
+
// re-post after a non-2xx that already committed writes a second row.
|
|
57
60
|
export { submitReview, fetchReviewDecision, reportAppEvent } from "./transport";
|
|
58
|
-
export type {
|
|
61
|
+
export type {
|
|
62
|
+
FetchReviewDecisionOptions,
|
|
63
|
+
ReportAppEventOptions,
|
|
64
|
+
ReviewSubmitResult,
|
|
65
|
+
} from "./transport";
|
|
66
|
+
// The same verdict under its shared name — `submitQuestionnaireResponse` resolves it too.
|
|
67
|
+
export type { SubmitResult } from "../utils/submitResult";
|
|
59
68
|
|
|
60
69
|
// ─── Gate-key + storage helpers (shared coachmark storage singleton) ──────────
|
|
61
70
|
export {
|
package/src/reviews/runtime.ts
CHANGED
|
@@ -31,7 +31,11 @@ export const resolveStorage = (
|
|
|
31
31
|
declare const __DEV__: boolean | undefined;
|
|
32
32
|
|
|
33
33
|
/** Once-per-process latch on its own `Symbol.for` slot: a plain `let` would warn once per inlined
|
|
34
|
-
* bundle copy, and the gates live in three of them.
|
|
34
|
+
* bundle copy, and the gates live in three of them.
|
|
35
|
+
*
|
|
36
|
+
* @globalSlot LATCH — written `true` by the first gate that found no sync storage, and never again.
|
|
37
|
+
* A second differing write re-arms a warning the process already spent, so a host that mounts three
|
|
38
|
+
* gates gets the same paragraph three times and stops reading it. */
|
|
35
39
|
const NO_STORAGE_WARNED_SLOT: unique symbol = Symbol.for(
|
|
36
40
|
"@wireai/activation:gateStorageWarned",
|
|
37
41
|
);
|
|
@@ -138,6 +142,12 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
|
|
|
138
142
|
// regardless of what the session registry does afterwards. The client counter only has to be
|
|
139
143
|
// monotone and per-launch; the server's own `min_sessions` still counts real `app.session_started`
|
|
140
144
|
// events, so nothing downstream needs the two ids to be identical.
|
|
145
|
+
//
|
|
146
|
+
// @globalSlot LATCH — pinned by the FIRST read and stable for the whole launch. This is the one
|
|
147
|
+
// slot whose latch is the fix: it samples the LIVE `currentSessionId` once, on purpose, so that
|
|
148
|
+
// registry changing underneath it (render before effect, on every cold start) cannot change the
|
|
149
|
+
// unit mid-launch and hand `minSessions: 2` a second "session" inside one app open. A second
|
|
150
|
+
// differing write is therefore not a stale-cache bug, it IS the defect — see the block above.
|
|
141
151
|
const PROCESS_OPEN_ID_SLOT: unique symbol = Symbol.for("@wireai/activation:processOpenId");
|
|
142
152
|
|
|
143
153
|
type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: string };
|
package/src/reviews/transport.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* transport.ts — kit → Wire server requests for the review module
|
|
3
|
-
* (analytics/reviews must never break the app). Mirrors analytics/reportClientEvent: a
|
|
4
|
-
* thin fetch wrapper
|
|
2
|
+
* transport.ts — kit → Wire server requests for the review module. Non-blocking and never
|
|
3
|
+
* throwing (analytics/reviews must never break the app). Mirrors analytics/reportClientEvent: a
|
|
4
|
+
* thin fetch wrapper with a Bearer tenant key. `submitReview` additionally REPORTS the fate of the
|
|
5
|
+
* row to its caller — accepted / rejected / unsent, three outcomes on purpose; the rest swallow
|
|
6
|
+
* every error.
|
|
5
7
|
*
|
|
6
8
|
* • submitReview → POST {serverUrl}/v1/reviews (the review row)
|
|
7
9
|
* • fetchReviewDecision → GET {serverUrl}/v1/reviews/decision (best-effort, the AI seam)
|
|
@@ -14,30 +16,74 @@
|
|
|
14
16
|
*/
|
|
15
17
|
import { ensureCurrentSessionId } from "../analytics/currentSession";
|
|
16
18
|
import { buildEventsRequest, type ClientEvent } from "../analytics/reportClientEvent";
|
|
19
|
+
import type { SubmitResult } from "../utils/submitResult";
|
|
17
20
|
import type { ReviewDecisionResponse, ReviewSubmission, ReviewTarget } from "./types";
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
23
|
+
* What became of a review POST — the SHARED three-outcome verdict, re-exported under the name the
|
|
24
|
+
* reviews surface has always published. The split is load-bearing and the reasoning lives once, in
|
|
25
|
+
* `utils/submitResult.ts`, because `submitQuestionnaireResponse` answers the identical question.
|
|
26
|
+
* See `submitReview` below for what reads it.
|
|
22
27
|
*/
|
|
23
|
-
export
|
|
28
|
+
export type ReviewSubmitResult = SubmitResult;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* POST a review (the 1-4 feedback path, and the text-less 5-star row).
|
|
32
|
+
*
|
|
33
|
+
* NON-BLOCKING, and ACKED. It never throws and never makes a caller wait — the request is fired
|
|
34
|
+
* synchronously and the UI is free to advance on the next line — but it RESOLVES with what became
|
|
35
|
+
* of the row.
|
|
36
|
+
*
|
|
37
|
+
* WHY THE RETURN VALUE EXISTS: a 1-4 star rating with mandatory free text is the kit's most
|
|
38
|
+
* valuable payload and it has no persisted queue behind it (unlike `analytics/eventQueue`). If the
|
|
39
|
+
* response is dropped on the floor, a caller cannot tell a delivered submission from one that died
|
|
40
|
+
* in the socket, so it must latch the rating as posted and a detractor who rated offline is lost
|
|
41
|
+
* AND never asked again. Reading the ack is what lets `ReviewGate` keep an undelivered row
|
|
42
|
+
* recoverable.
|
|
43
|
+
*
|
|
44
|
+
* ── WHY IT IS NOT A BOOLEAN ──────────────────────────────────────────────────────────────────
|
|
45
|
+
*
|
|
46
|
+
* It was, for exactly one unpublished release, and the boolean was the bug. `false` meant both
|
|
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. But the server mints
|
|
49
|
+
* its own row id (`create_review` / `_new_id()`) and `CreateReviewRequest` carries no id, so there
|
|
50
|
+
* is NO idempotency key on the wire: a 502 returned AFTER the insert commits means the re-post
|
|
51
|
+
* writes a SECOND row, double-counting `count` and corrupting `avg` — the precise corruption the
|
|
52
|
+
* 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.
|
|
54
|
+
*
|
|
55
|
+
* ── THE RESIDUAL, STATED HONESTLY ────────────────────────────────────────────────────────────
|
|
56
|
+
*
|
|
57
|
+
* `unsent` is not proof the server never got the row. A connection dropped after the request was
|
|
58
|
+
* written — or after the row committed — surfaces as a thrown/rejected `fetch` here, exactly like
|
|
59
|
+
* an offline device. Retrying only on `unsent` is therefore SAFER, not SAFE: it removes the
|
|
60
|
+
* double-post the server itself told us about, and leaves the narrow window where the answer never
|
|
61
|
+
* made it back onto the wire. Closing that window needs a CLIENT-MINTED IDEMPOTENCY KEY the server
|
|
62
|
+
* upserts on, which is a server change (`CreateReviewRequest` + `create_review`) and is not
|
|
63
|
+
* something the kit can fake. Until it exists, prefer losing a row over inventing one: a lost
|
|
64
|
+
* detractor is a gap in the data, a duplicated one is a lie in the data.
|
|
65
|
+
*/
|
|
66
|
+
export const submitReview = async (
|
|
24
67
|
target: ReviewTarget | undefined,
|
|
25
68
|
review: ReviewSubmission,
|
|
26
|
-
):
|
|
27
|
-
if (!target?.serverUrl) return;
|
|
69
|
+
): Promise<ReviewSubmitResult> => {
|
|
70
|
+
if (!target?.serverUrl) return "unsent";
|
|
28
71
|
try {
|
|
29
72
|
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/reviews`;
|
|
30
73
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
31
74
|
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
32
|
-
|
|
75
|
+
const res = await fetch(url, {
|
|
33
76
|
method: "POST",
|
|
34
77
|
headers,
|
|
35
78
|
body: JSON.stringify(review),
|
|
36
|
-
}).catch(() => {
|
|
37
|
-
/* best-effort, swallow */
|
|
38
79
|
});
|
|
80
|
+
// No response object at all is not an answer — treat it as nothing having reached the server
|
|
81
|
+
// rather than as a rejection, or a stubbed-out `fetch` would silently latch the row away.
|
|
82
|
+
if (!res) return "unsent";
|
|
83
|
+
return (res as { ok?: boolean }).ok ? "accepted" : "rejected";
|
|
39
84
|
} catch {
|
|
40
|
-
/* URL/JSON/missing-fetch —
|
|
85
|
+
/* URL/JSON/missing-fetch/network — nothing came back, and never surfaced to the UI */
|
|
86
|
+
return "unsent";
|
|
41
87
|
}
|
|
42
88
|
};
|
|
43
89
|
|
|
@@ -140,7 +140,7 @@ export const useLifecycleEvents = (
|
|
|
140
140
|
): string | undefined => {
|
|
141
141
|
// A HOST-supplied key is recorded on the process provenance registry, so a `<WireOnboarding>`
|
|
142
142
|
// mount that was not given one can tell "this app owns no device id" from "this app owns one
|
|
143
|
-
// and forgot it there" — the silent third id space
|
|
143
|
+
// and forgot it there" — the silent third id space. Recording only; nothing reads it here.
|
|
144
144
|
const host = resolveIdentity({
|
|
145
145
|
value: opts.deviceKey,
|
|
146
146
|
space: "device",
|
|
@@ -3,12 +3,60 @@
|
|
|
3
3
|
* themed card via `useOnboardingTheme()`. WHY a context rather than prop-drilling:
|
|
4
4
|
* the cards are mounted by the SDK's ComponentRenderer (not by us), so we can't
|
|
5
5
|
* pass them props directly — they must read the theme from context.
|
|
6
|
+
*
|
|
7
|
+
* ── WHY THE CONTEXT LIVES ON A `Symbol.for` SLOT ───────────────────────────────────────────────
|
|
8
|
+
*
|
|
9
|
+
* It was a plain `createContext(...)`, and tsup inlines this module into FIVE dist bundles
|
|
10
|
+
* (`.`, `./reviews`, `./questionnaire`, `./coachmarks`, `./showcase`) — so each one carried its own
|
|
11
|
+
* context instance. `OnboardingThemeProvider` is exported ONLY from the root barrel, so on any
|
|
12
|
+
* `dist` resolution path (plain RN CLI, `unstable_enablePackageExports: false`, node, jest, SSR) a
|
|
13
|
+
* host that wrapped its tree in `<OnboardingThemeProvider>` and rendered `<ReviewGate>` from
|
|
14
|
+
* `@wireai/activation/reviews` was writing one context and reading another. Nothing crashed and
|
|
15
|
+
* nothing warned: `useOnboardingTheme`'s `ctx ?? mergeTheme()` fallback quietly served the NEUTRAL
|
|
16
|
+
* default theme, so the tenant's brand simply did not apply to the gate. On-device this was masked
|
|
17
|
+
* only because Metro's `react-native` export condition collapses every subpath back to this one
|
|
18
|
+
* `src/` file — the same "bundler accident, not a guarantee" the `currentSession` header names, and
|
|
19
|
+
* the same root cause as the optional-peers bug 0.13.3 fixed.
|
|
20
|
+
*
|
|
21
|
+
* The bundler-agnostic fix, exactly as `coachmarks/runtime`, `analytics/currentSession`,
|
|
22
|
+
* `context/deviceId` and `features/WireFeaturesProvider` already do it: park the ONE context on a
|
|
23
|
+
* well-known `globalThis` slot keyed by `Symbol.for(...)`. `Symbol.for` uses the runtime-global
|
|
24
|
+
* symbol registry, so every inlined copy of this module resolves the SAME symbol, finds the slot
|
|
25
|
+
* already filled, and reuses that instance — one context identity however many copies exist.
|
|
26
|
+
* `globalThis` is present and identical in Hermes/React Native, Node and SSR (we never touch
|
|
27
|
+
* `window`), so this is safe on every host. `test/canary/themeContextIdentity.test.tsx` measures it
|
|
28
|
+
* in the BUILT artifact, because the defect is invisible in source.
|
|
6
29
|
*/
|
|
7
30
|
import React, { createContext, useContext, useMemo } from "react";
|
|
8
31
|
import { mergeTheme } from "./defaultTheme";
|
|
9
32
|
import type { OnboardingTheme } from "./types";
|
|
10
33
|
|
|
11
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Well-known key into the runtime-global symbol registry. `Symbol.for` (NOT a plain `Symbol()`) is
|
|
36
|
+
* what makes this cross-bundle: it returns the SAME symbol for the same string across every copy of
|
|
37
|
+
* this module, so duplicated inlined copies all address one slot.
|
|
38
|
+
*
|
|
39
|
+
* @globalSlot LATCH — the React context object is minted by the first copy of this module to load
|
|
40
|
+
* and every later copy ADOPTS it (the `if (!…)` guard below says so in one line). A second write
|
|
41
|
+
* re-splits the identity: `<OnboardingThemeProvider>` (root barrel only) publishes into one context
|
|
42
|
+
* while `<ReviewGate>` from a subpath reads another, and `useOnboardingTheme`'s `ctx ?? mergeTheme()`
|
|
43
|
+
* fallback quietly serves the NEUTRAL theme. Because it is a latch, and only because of that, the
|
|
44
|
+
* module-local `const OnboardingThemeContext` below may cache it.
|
|
45
|
+
*/
|
|
46
|
+
const THEME_CONTEXT_SLOT: unique symbol = Symbol.for("@wireai/activation:themeContext");
|
|
47
|
+
|
|
48
|
+
type GlobalWithThemeContext = typeof globalThis & {
|
|
49
|
+
[THEME_CONTEXT_SLOT]?: React.Context<OnboardingTheme | null>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const themeGlobal = globalThis as GlobalWithThemeContext;
|
|
53
|
+
|
|
54
|
+
// First copy to load creates the context; every later copy adopts it. Never overwrite an existing
|
|
55
|
+
// slot — a second `createContext` here would re-split the identity this guard exists to keep whole.
|
|
56
|
+
if (!themeGlobal[THEME_CONTEXT_SLOT]) {
|
|
57
|
+
themeGlobal[THEME_CONTEXT_SLOT] = createContext<OnboardingTheme | null>(null);
|
|
58
|
+
}
|
|
59
|
+
const OnboardingThemeContext = themeGlobal[THEME_CONTEXT_SLOT] as React.Context<OnboardingTheme | null>;
|
|
12
60
|
|
|
13
61
|
export type OnboardingThemeProviderProps = {
|
|
14
62
|
/** Partial overrides merged over the neutral default theme. */
|
package/src/types.ts
CHANGED
|
@@ -340,7 +340,7 @@ export type OnboardingProgress = {
|
|
|
340
340
|
* `answers.interests` silently becomes `answers.what_are_you_into_v2`, with no error anywhere. A
|
|
341
341
|
* slot is the question's identity independent of its wording.
|
|
342
342
|
*
|
|
343
|
-
* FULLY ADDITIVE, AND LIVE SINCE 2026-07-28
|
|
343
|
+
* FULLY ADDITIVE, AND LIVE SINCE 2026-07-28. The deployed server sends it on
|
|
344
344
|
* `progress` for every AI-GENERATED question, as `adaptive_<n>` 1-based over adaptive answers, and
|
|
345
345
|
* for a CONFIGURED question only when the tenant set one. Where the tenant set none the field is
|
|
346
346
|
* simply absent. Every fallback is PER-CARD, so a thread that mixes slotted and unslotted cards
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* submitResult — the ONE delivery verdict every user-payload POST in this kit reports back.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT IS SHARED, AND WHY IT IS NOT A BOOLEAN. Two surfaces post a payload the user typed —
|
|
5
|
+
* `submitReview` (`reviews/transport.ts`) and `submitQuestionnaireResponse`
|
|
6
|
+
* (`questionnaire/transport.ts`) — and NEITHER has a persisted queue behind it (unlike
|
|
7
|
+
* `analytics/eventQueue`, with its `maxSize`/`maxRetries`/backoff). Both sit behind a permanent
|
|
8
|
+
* once-gate: the host writes `wire_review_<id>_seen` / `wire_questionnaire_<id>_<ver>_seen` when the
|
|
9
|
+
* gate resolves, so a payload that dies in the socket is lost AND the user is never asked again.
|
|
10
|
+
* Reading the fate of the request is the only thing that lets a caller keep an undelivered payload
|
|
11
|
+
* recoverable, so the two surfaces must answer the same question in the same words.
|
|
12
|
+
*
|
|
13
|
+
* • `accepted` — the server answered 2xx. Stored.
|
|
14
|
+
* • `rejected` — the server ANSWERED, non-2xx. It was reached; it either stored the payload and
|
|
15
|
+
* lost the ack (a gateway 502 after the write commits) or deliberately refused it.
|
|
16
|
+
* Either way the server owns the outcome and a re-post risks a SECOND row.
|
|
17
|
+
* • `unsent` — nothing came back at all: no target, a missing/throwing `fetch`, a rejected
|
|
18
|
+
* request. **This is the ONLY outcome a caller may retry on.**
|
|
19
|
+
*
|
|
20
|
+
* ── WHY THREE VALUES AND NOT TWO (pinned here so the defect cannot move surface) ───────────────
|
|
21
|
+
*
|
|
22
|
+
* `submitReview` was a boolean for exactly one unpublished release, and the boolean WAS the bug.
|
|
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. But the server mints its own row id
|
|
25
|
+
* (`create_review` / `_new_id()`), and neither `CreateReviewRequest` nor the questionnaire response
|
|
26
|
+
* body carries a client id, so there is NO idempotency key on the wire: a 502 returned AFTER the
|
|
27
|
+
* insert commits means the re-post writes a SECOND row. A response of any status proves the server
|
|
28
|
+
* was reached, and that is a different question from whether it liked the payload.
|
|
29
|
+
*
|
|
30
|
+
* ── THE RESIDUAL, STATED HONESTLY ─────────────────────────────────────────────────────────────
|
|
31
|
+
*
|
|
32
|
+
* `unsent` is not proof the server never got it. A connection dropped after the request was written
|
|
33
|
+
* — or after the row committed — surfaces as a thrown/rejected `fetch`, exactly like an offline
|
|
34
|
+
* device. Retrying only on `unsent` is therefore SAFER, not SAFE. Closing that window needs a
|
|
35
|
+
* CLIENT-MINTED IDEMPOTENCY KEY the server upserts on, which is a server change and not something
|
|
36
|
+
* the kit can fake. Until it exists, prefer losing a row over inventing one: a lost detractor is a
|
|
37
|
+
* gap in the data, a duplicated one is a lie in the data.
|
|
38
|
+
*/
|
|
39
|
+
export type SubmitResult = "accepted" | "rejected" | "unsent";
|