@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
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
import { O as OnboardingTheme } from './types-BKfpdZzX.js';
|
|
2
2
|
import { C as CoachmarkStorage } from './types-BcmagF6K.js';
|
|
3
|
-
import {
|
|
3
|
+
import { a as WireFeatures, b as WireFeaturesConfig } from './types-BpwiRpA8.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* submitResult — the ONE delivery verdict every user-payload POST in this kit reports back.
|
|
7
|
+
*
|
|
8
|
+
* WHY IT IS SHARED, AND WHY IT IS NOT A BOOLEAN. Two surfaces post a payload the user typed —
|
|
9
|
+
* `submitReview` (`reviews/transport.ts`) and `submitQuestionnaireResponse`
|
|
10
|
+
* (`questionnaire/transport.ts`) — and NEITHER has a persisted queue behind it (unlike
|
|
11
|
+
* `analytics/eventQueue`, with its `maxSize`/`maxRetries`/backoff). Both sit behind a permanent
|
|
12
|
+
* once-gate: the host writes `wire_review_<id>_seen` / `wire_questionnaire_<id>_<ver>_seen` when the
|
|
13
|
+
* gate resolves, so a payload that dies in the socket is lost AND the user is never asked again.
|
|
14
|
+
* Reading the fate of the request is the only thing that lets a caller keep an undelivered payload
|
|
15
|
+
* recoverable, so the two surfaces must answer the same question in the same words.
|
|
16
|
+
*
|
|
17
|
+
* • `accepted` — the server answered 2xx. Stored.
|
|
18
|
+
* • `rejected` — the server ANSWERED, non-2xx. It was reached; it either stored the payload and
|
|
19
|
+
* lost the ack (a gateway 502 after the write commits) or deliberately refused it.
|
|
20
|
+
* Either way the server owns the outcome and a re-post risks a SECOND row.
|
|
21
|
+
* • `unsent` — nothing came back at all: no target, a missing/throwing `fetch`, a rejected
|
|
22
|
+
* request. **This is the ONLY outcome a caller may retry on.**
|
|
23
|
+
*
|
|
24
|
+
* ── WHY THREE VALUES AND NOT TWO (pinned here so the defect cannot move surface) ───────────────
|
|
25
|
+
*
|
|
26
|
+
* `submitReview` was a boolean for exactly one unpublished release, and the boolean WAS the bug.
|
|
27
|
+
* `false` meant both "nothing reached the server" and "the server answered non-2xx", and the caller
|
|
28
|
+
* treats `false` as "still owed" and re-posts. But the server mints its own row id
|
|
29
|
+
* (`create_review` / `_new_id()`), and neither `CreateReviewRequest` nor the questionnaire response
|
|
30
|
+
* body carries a client id, so there is NO idempotency key on the wire: a 502 returned AFTER the
|
|
31
|
+
* insert commits means the re-post writes a SECOND row. A response of any status proves the server
|
|
32
|
+
* was reached, and that is a different question from whether it liked the payload.
|
|
33
|
+
*
|
|
34
|
+
* ── THE RESIDUAL, STATED HONESTLY ─────────────────────────────────────────────────────────────
|
|
35
|
+
*
|
|
36
|
+
* `unsent` is not proof the server never got it. A connection dropped after the request was written
|
|
37
|
+
* — or after the row committed — surfaces as a thrown/rejected `fetch`, exactly like an offline
|
|
38
|
+
* device. Retrying only on `unsent` is therefore SAFER, not SAFE. Closing that window needs a
|
|
39
|
+
* CLIENT-MINTED IDEMPOTENCY KEY the server upserts on, which is a server change and not something
|
|
40
|
+
* the kit can fake. Until it exists, prefer losing a row over inventing one: a lost detractor is a
|
|
41
|
+
* gap in the data, a duplicated one is a lie in the data.
|
|
42
|
+
*/
|
|
43
|
+
type SubmitResult = "accepted" | "rejected" | "unsent";
|
|
4
44
|
|
|
5
45
|
/**
|
|
6
46
|
* The firing verdict — the SAME contract on both sides of the seam. `decideReview`
|
|
@@ -161,4 +201,4 @@ interface ReviewGateController {
|
|
|
161
201
|
markResolved: () => void;
|
|
162
202
|
}
|
|
163
203
|
|
|
164
|
-
export type { ReviewGateProps as R, UseReviewGateOptions as U, ReviewPresentation as a, ReviewGateController as b, ReviewDecision as c, ReviewStoreConfig as d, RatingRoute as e, ReviewConfig as f, ReviewDecisionResponse as g, ReviewGateEvent as h, ReviewGateEventName as i, ReviewSubmission as j, ReviewTarget as k };
|
|
204
|
+
export type { ReviewGateProps as R, SubmitResult as S, UseReviewGateOptions as U, ReviewPresentation as a, ReviewGateController as b, ReviewDecision as c, ReviewStoreConfig as d, RatingRoute as e, ReviewConfig as f, ReviewDecisionResponse as g, ReviewGateEvent as h, ReviewGateEventName as i, ReviewSubmission as j, ReviewTarget as k };
|
package/llms.txt
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
The package is more than `<WireOnboarding>`. Tree-shakeable subpath exports (each keeps the onboarding UI out of an analytics-only bundle):
|
|
32
32
|
|
|
33
|
-
- `@wireai/activation/analytics`: `createAnalytics({ serverUrl, apiKey, storage })` (Segment/PostHog-shaped `track` / `screen` / `identify` / `setUserContext` / `reset` over an offline-first queue), `createScreenTracker` / `screenTrackingHandler` (auto screen views), `reportAppEvent`, `createEventQueue`, `clearUserContext` (logout). Rich `WireUserContext` supports an opt-in `userEmail` (raw by default, `hashEmail` to fold) and auto-mints a persisted per-install `device_key`. `identify` refuses email-shaped ids unless `allowEmailAsUserId`. See the README "Rich user context & PII" section.
|
|
33
|
+
- `@wireai/activation/analytics`: `createAnalytics({ serverUrl, apiKey, storage })` (Segment/PostHog-shaped `track` / `screen` / `identify` / `setUserContext` / `reset` over an offline-first queue), `createScreenTracker` / `screenTrackingHandler` (auto screen views), `reportAppEvent`, `createEventQueue`, `clearUserContext` (logout). Rich `WireUserContext` supports an opt-in `userEmail` (raw by default, `hashEmail` to fold) and auto-mints a persisted per-install `device_key`. `identify` refuses email-shaped ids unless `allowEmailAsUserId`. See the README "Rich user context & PII" section. Also `wireDoctor({ target, storage })`, the DEV-ONLY self-check: four `{name, ok, detail}` checks (target shape, server reachability, storage writability, and a `dry_run` round trip whose verdict is `written === 1 && skipped === 0` read from the ack body, because `/v1/events` answers 200 for a batch it discards). It never throws, never prints any part of the apiKey, and does nothing at all when `__DEV__` is unset.
|
|
34
34
|
- `@wireai/activation/reviews`: the in-app review gate. `useReviewGate`, `fetchReviewDecision` (server AI seam; a `{fire:false}` survives intact), `ReviewGate` UI. `minSessions` defaults to 2 (never prompts on the first session; a server decision still overrides).
|
|
35
35
|
- `@wireai/activation/questionnaire`: the pre-onboarding questionnaire gate. `useQuestionnaireGate`, `fetchQuestionnaireDecision` (same server seam; a body without a boolean `fire` resolves to null, never shows).
|
|
36
36
|
- `createRevenueCatBridge({ analytics, entitlementId })` (ROOT-exported) → the RevenueCat purchase funnel: `paywallShown` / `checkoutStarted` / `purchaseCompleted` / `purchaseFailed` / `purchasesRestored` / `syncPlanTier`, emitting the canonical `wire_paywall_shown` / `wire_checkout_started` / `wire_purchase_completed` / `wire_purchase_failed` / `wire_purchase_restored` names. The kit does NOT depend on `react-native-purchases` (a native module); the RevenueCat objects are typed structurally, so a host passes the real ones. THE JOIN KEY between a purchase and an onboarding is `user_context.device_key`, never `session_id` (an onboarding session id is the A2A contextId, an app-event session id is the per-open id, and they are separate spaces): pass the same device key to `createAnalytics`/`createWireActivation` AND to `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
|
|
6
6
|
"author": "Malik Chohra <malik@getwireai.com>",
|
|
@@ -137,8 +137,12 @@
|
|
|
137
137
|
"scripts": {
|
|
138
138
|
"build": "tsup",
|
|
139
139
|
"prepack": "npm run build",
|
|
140
|
+
"prepublishOnly": "npm run build && npm run test:canary",
|
|
140
141
|
"typecheck": "tsc --noEmit",
|
|
142
|
+
"pretest": "npm run build",
|
|
141
143
|
"test": "node --test --experimental-strip-types --import ./test/register-ts-resolver.mjs \"src/**/*.test.ts\" \"test/canary/**/*.test.tsx\"",
|
|
144
|
+
"test:canary": "node --test --experimental-strip-types --import ./test/register-ts-resolver.mjs \"test/canary/**/*.test.tsx\"",
|
|
145
|
+
"check:contract": "node --no-warnings --experimental-strip-types scripts/check-events-contract.mjs",
|
|
142
146
|
"pack:tarball": "npm pack"
|
|
143
147
|
},
|
|
144
148
|
"peerDependencies": {
|
|
@@ -180,6 +184,7 @@
|
|
|
180
184
|
"devDependencies": {
|
|
181
185
|
"@blazejkustra/react-native-onboarding": "^1.0.1",
|
|
182
186
|
"@types/react": "^19.0.0",
|
|
187
|
+
"ajv": "8.20.0",
|
|
183
188
|
"expo-blur": "^15.0.0",
|
|
184
189
|
"react": "^19.2.7",
|
|
185
190
|
"react-native": "^0.86.0",
|
package/src/OnboardingFlow.tsx
CHANGED
|
@@ -179,7 +179,17 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
179
179
|
// Trailing-edge debounce timer for prefetch-on-select (see previewOnSelect below).
|
|
180
180
|
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
181
181
|
|
|
182
|
-
|
|
182
|
+
// The attempt generation. It lives in STATE, not in a ref, and both the auto-start effect and
|
|
183
|
+
// the startup watchdog list it in their deps — a retry has to genuinely RE-ARM both, and a ref
|
|
184
|
+
// cannot re-trigger an effect. Nothing else in those deps moves on a retry: `sendMessage` is
|
|
185
|
+
// memoized in wireai-rn on [registry, provider, model], and `lastCard` is still `undefined`
|
|
186
|
+
// because no card ever arrived. Without the generation the kickoff is never re-sent, `timedOut`
|
|
187
|
+
// can never become true a second time, and the retry budget can never be spent — which leaves a
|
|
188
|
+
// backend that never answers sitting on the loader forever.
|
|
189
|
+
const [runId, setRunId] = useState(0);
|
|
190
|
+
// The generation whose kickoff has already been sent. -1 = nothing sent yet (so the very first
|
|
191
|
+
// run can be told apart from a retry, which must NOT re-fire `started`/`resumed`).
|
|
192
|
+
const startedRunRef = useRef(-1);
|
|
183
193
|
const finished = useRef(false);
|
|
184
194
|
const errored = useRef(false);
|
|
185
195
|
const attempts = useRef(0);
|
|
@@ -210,33 +220,50 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
210
220
|
const progress = useMemo(() => readProgress(lastCard?.response), [lastCard]);
|
|
211
221
|
|
|
212
222
|
// 1. Auto-start: the backend treats the first inbound message as "begin".
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
223
|
+
// ONLY the very first attempt of a fresh session sends `startMessage`. Every other kickoff
|
|
224
|
+
// — a RESUMED session (persisted id restored after an app kill) and every RETRY — sends the
|
|
225
|
+
// skip sentinel, because both can reach a server that already has a question PENDING on this
|
|
226
|
+
// session, and a non-empty message arriving against a pending question is recorded as that
|
|
227
|
+
// question's ANSWER (server engine dispatch: `if user_message and pending and not
|
|
228
|
+
// _stop_requested(...)` appends `{key, question, answer}` to `session["answers"]`). For a
|
|
229
|
+
// retry that is not hypothetical: the watchdog fires at `startTimeoutMs` while a slow LLM is
|
|
230
|
+
// still generating, so the server can genuinely hold card 1 pending when the retry lands, and
|
|
231
|
+
// the user's first onboarding answer becomes the literal kickoff text — silently.
|
|
232
|
+
// THE COST, stated plainly: when the server DID have card 1 pending, the sentinel SKIPS that
|
|
233
|
+
// card — the user loses one question and the server logs a `screen_skipped`. Deliberate. A
|
|
234
|
+
// skipped question is visible in the funnel and recorded as nothing; a kickoff string stored
|
|
235
|
+
// as the user's first answer is invisible and poisons the data the product is built on.
|
|
236
|
+
// When the server has no pending (restart/expiry, or a retry whose predecessor never landed)
|
|
237
|
+
// the sentinel is zeroed and falls through — it degrades cleanly to a normal kickoff under
|
|
238
|
+
// the same id, which is what makes it safe to send on an attempt that may be either.
|
|
219
239
|
useEffect(() => {
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
|
|
240
|
+
if (startedRunRef.current === runId) return;
|
|
241
|
+
const isFirstRun = startedRunRef.current < 0;
|
|
242
|
+
startedRunRef.current = runId;
|
|
243
|
+
sendMessage(isFirstRun && !resumed ? startMessage : SKIP_ONE_SENTINEL);
|
|
244
|
+
// Only the FIRST run is a session start. A retry re-kicks the flow (with the sentinel) but
|
|
245
|
+
// must not re-fire `started`/`resumed`, or every host funnel double-counts the session; the
|
|
246
|
+
// retry itself is already surfaced as a `retry` event by the error effect below.
|
|
247
|
+
if (!isFirstRun) return;
|
|
223
248
|
// Surface the session id (the A2A contextId) on the start event so a host can capture it
|
|
224
249
|
// and bind a user AFTER the flow via `identifyOnboarding({ contextId })` — completion clears
|
|
225
250
|
// the persisted session, so the in-flight event is the reliable post-flow handle. sessionId
|
|
226
|
-
// is stable for the whole session (minted before this subtree mounts), so this stays
|
|
251
|
+
// is stable for the whole session (minted before this subtree mounts), so this stays once
|
|
252
|
+
// per session.
|
|
227
253
|
onEventRef.current?.(
|
|
228
254
|
resumed
|
|
229
255
|
? { type: "resumed", contextId: sessionId }
|
|
230
256
|
: { type: "started", contextId: sessionId },
|
|
231
257
|
);
|
|
232
|
-
}, [sendMessage, startMessage, resumed, sessionId]);
|
|
258
|
+
}, [sendMessage, startMessage, resumed, sessionId, runId]);
|
|
233
259
|
|
|
234
|
-
// 2. Startup watchdog.
|
|
260
|
+
// 2. Startup watchdog. `runId` is load-bearing in these deps: after a retry there is still no
|
|
261
|
+
// card, so `lastCard` does not change and nothing else would re-arm the timer.
|
|
235
262
|
useEffect(() => {
|
|
236
263
|
if (lastCard) return;
|
|
237
264
|
const timer = setTimeout(() => setTimedOut(true), startTimeoutMs);
|
|
238
265
|
return () => clearTimeout(timer);
|
|
239
|
-
}, [lastCard, startTimeoutMs]);
|
|
266
|
+
}, [lastCard, startTimeoutMs, runId]);
|
|
240
267
|
|
|
241
268
|
// A new card arrived → clear any prior inline validation error + emit a turn event.
|
|
242
269
|
useEffect(() => {
|
|
@@ -287,8 +314,10 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
287
314
|
// Does NOT clear the attempt counter or the degraded flag — the caller decides that.
|
|
288
315
|
const restartThread = useCallback(() => {
|
|
289
316
|
setTimedOut(false);
|
|
290
|
-
started.current = false;
|
|
291
317
|
errored.current = false;
|
|
318
|
+
// Bumping the generation IN STATE is what actually re-runs the kickoff effect and re-arms the
|
|
319
|
+
// watchdog. Flipping a ref here would re-trigger neither.
|
|
320
|
+
setRunId((n) => n + 1);
|
|
292
321
|
reset();
|
|
293
322
|
}, [reset]);
|
|
294
323
|
|
package/src/WireOnboarding.tsx
CHANGED
|
@@ -114,7 +114,8 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
114
114
|
// `activated` funnel rather than an error. Two real consumers shipped without it.
|
|
115
115
|
//
|
|
116
116
|
// A host-supplied key is also RECORDED on the process-wide provenance registry, so a sibling
|
|
117
|
-
// surface can later ask "does this app own a device id?" — see the
|
|
117
|
+
// surface can later ask "does this app own a device id?" — see the third-id-space warning below.
|
|
118
|
+
// The registry
|
|
118
119
|
// write is idempotent (same key, same value), so running it from a memo is safe under StrictMode's
|
|
119
120
|
// double-invoke; it lives here rather than in an effect so the answer is already true for any
|
|
120
121
|
// surface constructed later in the same tick.
|
|
@@ -143,7 +144,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
143
144
|
// counter rather than leaving it empty, and it inflates distinct-device counts on top. Same
|
|
144
145
|
// reason `useLifecycleEvents` gates its own fallback on storage.
|
|
145
146
|
//
|
|
146
|
-
// ⚠️ 0.13.0
|
|
147
|
+
// ⚠️ 0.13.0: the third condition used to be `Boolean(storage)` — the PRESENCE of the prop,
|
|
147
148
|
// never the SUCCESS of the write. A REJECTING adapter (a locked / full / permission-denied
|
|
148
149
|
// AsyncStorage, the most common real breakage) therefore walked the per-launch key in through the
|
|
149
150
|
// front door: `hydrateAutoDeviceKey` resolves to the in-memory mint on every failure branch, and a
|
|
@@ -153,8 +154,8 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
153
154
|
//
|
|
154
155
|
// It resolves through `hydrateDeviceIdentity`, never the sync `resolveAutoDeviceKey`: the sync
|
|
155
156
|
// contract returns the freshly minted id and adopts the persisted one milliseconds later, which
|
|
156
|
-
// is exactly the 0.12.1
|
|
157
|
-
// stamps, not a fresh mint per launch.
|
|
157
|
+
// is exactly the split-identity defect 0.12.1 shipped — the key stamped here must be the one the
|
|
158
|
+
// analytics side stamps, not a fresh mint per launch. It is therefore awaited BEHIND THE LOADER GATE
|
|
158
159
|
// below (the same gate the persisted-session read already holds, and it is only ever open when
|
|
159
160
|
// `storage` is present anyway), because `userContext` feeds the `llm` memo: swapping it after the
|
|
160
161
|
// provider mounted would recreate the A2A adapter and drop the server-learned `contextId`.
|
|
@@ -201,7 +202,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
201
202
|
[userContextStable, injectedJoinKey],
|
|
202
203
|
);
|
|
203
204
|
|
|
204
|
-
// THE JOIN DECISION IS FROZEN AT THE MOMENT THE GATE OPENS
|
|
205
|
+
// THE JOIN DECISION IS FROZEN AT THE MOMENT THE GATE OPENS, exactly as `startupUserIdRef`
|
|
205
206
|
// below freezes the user id, and for the identical reason: `userContext` feeds the `llm` memo, and
|
|
206
207
|
// wireai-rn recreates its A2A adapter whenever the `llm` identity changes — the adapter ctor resets
|
|
207
208
|
// `contextId` (`this.contextId = void 0`), so the server-learned session id is dropped.
|
|
@@ -261,7 +262,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
261
262
|
);
|
|
262
263
|
}, [warnMissingJoinKey, autoJoinReason]);
|
|
263
264
|
|
|
264
|
-
// THE SECOND CASE AUTO-JOIN WOULD OTHERWISE SILENCE
|
|
265
|
+
// THE SECOND CASE AUTO-JOIN WOULD OTHERWISE SILENCE. 0.12.2 suppressed the missing-join-key
|
|
265
266
|
// warning whenever injection succeeded, which is right when the host genuinely owns no device id —
|
|
266
267
|
// and wrong when it owns one and simply forgot it HERE. That host used to get a loud warning and
|
|
267
268
|
// now gets a silent third id space: its app events under its own key, this onboarding session under
|
|
@@ -24,7 +24,15 @@
|
|
|
24
24
|
* the same symbol and shares one store, on Hermes/RN, Node and SSR alike.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Well-known key into the runtime-global symbol registry — one shared store across every bundle.
|
|
29
|
+
*
|
|
30
|
+
* @globalSlot LATCH — the STORE OBJECT is created once and its identity is then stable. A second
|
|
31
|
+
* write replaces the `listeners` set every existing subscriber is registered in, so their
|
|
32
|
+
* unsubscribes free the wrong set and a bump reaches nobody — silently, which is the whole failure
|
|
33
|
+
* class this slot exists to close. Its CONTENTS (`version`, `listeners`) are live and mutated in
|
|
34
|
+
* place, which is why every accessor here goes through `store()` instead of holding a reference.
|
|
35
|
+
*/
|
|
28
36
|
const REVALIDATION_SLOT: unique symbol = Symbol.for(
|
|
29
37
|
"@wireai/activation:activationRevalidation",
|
|
30
38
|
);
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* because the kit didn't own them — an awaitable action POST (`wireTrackActionAwait`), its OWN
|
|
6
6
|
* per-open session id (`getWireSessionId`, minted because the kit's `getCurrentSessionId` desynced
|
|
7
7
|
* cross-bundle — defect B), and an await-then-bump revalidation dance (`bumpActivationDecision`).
|
|
8
|
-
* Defect B is fixed
|
|
9
|
-
* `dist`
|
|
8
|
+
* Defect B is fixed — `getCurrentSessionId` is now a `globalThis` singleton, reliable under
|
|
9
|
+
* `dist` — so the kit can own all three behind one thin surface:
|
|
10
10
|
*
|
|
11
11
|
* const wire = createWireActivation({ serverUrl, apiKey }); // or useWireActivation(...)
|
|
12
12
|
* await wire.track("journal_done"); // awaitable POST + auto-revalidate
|
|
@@ -108,7 +108,7 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
|
|
|
108
108
|
const explicitDeviceKey = clean(config.deviceKey) ?? clean(config.userContext?.deviceKey);
|
|
109
109
|
// Record a HOST-supplied key on the process provenance registry, so a `<WireOnboarding>` mount that
|
|
110
110
|
// was NOT given one can tell "this app owns no device id" (fine, inject) from "this app owns one
|
|
111
|
-
// and forgot it here" (the silent third id space
|
|
111
|
+
// and forgot it here" (the silent third id space). Recording only; nothing reads it on this path.
|
|
112
112
|
resolveIdentity({
|
|
113
113
|
value: explicitDeviceKey,
|
|
114
114
|
space: "device",
|
|
@@ -198,7 +198,7 @@ export const createAnalytics = (
|
|
|
198
198
|
: undefined;
|
|
199
199
|
// Record a HOST-supplied key on the process provenance registry, so a `<WireOnboarding>` mount that
|
|
200
200
|
// was NOT given one can tell "this app owns no device id" (fine, inject) from "this app owns one
|
|
201
|
-
// and forgot it here" (the silent third id space
|
|
201
|
+
// and forgot it here" (the silent third id space). Recording only; nothing reads it here.
|
|
202
202
|
resolveIdentity({
|
|
203
203
|
value: hostDeviceKeyAtInit,
|
|
204
204
|
space: "device",
|
|
@@ -333,7 +333,7 @@ export const createAnalytics = (
|
|
|
333
333
|
|
|
334
334
|
const screen = (name: string, props?: AnalyticsProps): void => {
|
|
335
335
|
if (!name) return;
|
|
336
|
-
// Reuse the
|
|
336
|
+
// Reuse the existing screen event shape (question_key='screen'); route through the queue for offline-first.
|
|
337
337
|
const meta = { screen: name, ...(props ?? {}) };
|
|
338
338
|
const clientEvent: ClientEvent = {
|
|
339
339
|
event_type: "app_event",
|
|
@@ -59,6 +59,12 @@ import { warnInDev } from "../utils/warnInDev";
|
|
|
59
59
|
* Well-known key into the runtime-global symbol registry. `Symbol.for` (NOT a plain `Symbol()`) is
|
|
60
60
|
* what makes this cross-bundle: it returns the SAME symbol for the same string across every copy of
|
|
61
61
|
* this module, so duplicated inlined copies all address one slot.
|
|
62
|
+
*
|
|
63
|
+
* @globalSlot LIVE — every app-open overwrites this with that open's id, so a reader that captures
|
|
64
|
+
* it into a module-local (or a `const` taken once at mount) posts the PREVIOUS open's session to a
|
|
65
|
+
* server that has already moved on. Read it at the moment of use, through `getCurrentSessionId()` /
|
|
66
|
+
* `ensureCurrentSessionId()`. `reviews/runtime`'s `currentOpenId()` deliberately pins one sample of
|
|
67
|
+
* it for the launch; that is a documented latch DERIVED from this slot, not a cache of it.
|
|
62
68
|
*/
|
|
63
69
|
const CURRENT_SESSION_ID_SLOT: unique symbol = Symbol.for(
|
|
64
70
|
"@wireai/activation:currentSessionId",
|
|
@@ -100,6 +106,10 @@ const MINT_WARNING =
|
|
|
100
106
|
* cross-bundle reason as the id itself: a plain module `let` would warn once per inlined copy, i.e.
|
|
101
107
|
* once per bundle, not once per process. NOT cleared by `resetCurrentSessionId`: "warn once" is a
|
|
102
108
|
* process-lifetime promise, and a test that resets the id between mints is still one process.
|
|
109
|
+
*
|
|
110
|
+
* @globalSlot LATCH — written `true` on the first mint that actually warned, and never again. A
|
|
111
|
+
* second differing write would re-arm a warning the process has already spent, turning "once" into
|
|
112
|
+
* "once per whoever cleared it".
|
|
103
113
|
*/
|
|
104
114
|
const MINT_WARNED_SLOT: unique symbol = Symbol.for(
|
|
105
115
|
"@wireai/activation:currentSessionIdMintWarned",
|
|
@@ -86,7 +86,7 @@ const DEFAULTS = {
|
|
|
86
86
|
/** Ceiling on the persisted-backlog read — a hung adapter degrades to an empty start, never a stall. */
|
|
87
87
|
const READ_TIMEOUT_MS = 1500;
|
|
88
88
|
|
|
89
|
-
// ── One storage slot per QUEUE, not per appId
|
|
89
|
+
// ── One storage slot per QUEUE, not per appId ────────────────────────────────────────────────────
|
|
90
90
|
//
|
|
91
91
|
// The default key was derived from `appId` alone, so two `createAnalytics` instances for one tenant —
|
|
92
92
|
// the documented double-wiring, a façade for `track`/`screen` plus an activation instance — shared ONE
|
|
@@ -99,6 +99,11 @@ const READ_TIMEOUT_MS = 1500;
|
|
|
99
99
|
// A `Symbol.for` slot for the same reason as every other registry here: tsup inlines a copy of this
|
|
100
100
|
// module into each bundle, and a plain module `let` would let the `.` and `./analytics` copies each
|
|
101
101
|
// think they were the first claimant of the same key.
|
|
102
|
+
//
|
|
103
|
+
// @globalSlot LATCH — the claimed-key Set is created once and its identity is then stable. A second
|
|
104
|
+
// write hands the next queue an EMPTY claim list, so it re-claims a key a live queue already owns
|
|
105
|
+
// and the two silently share one persisted backlog again. Its MEMBERS are live, which is why
|
|
106
|
+
// `claimedQueueKeys()` re-reads the slot on every call rather than caching the Set.
|
|
102
107
|
const QUEUE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:eventQueueKeys");
|
|
103
108
|
|
|
104
109
|
type GlobalWithQueueKeys = typeof globalThis & { [QUEUE_KEY_SLOT]?: Set<string> };
|
|
@@ -153,10 +158,19 @@ type QueuedItem = { id: number; event: ClientEvent; sig: string };
|
|
|
153
158
|
/** Persisted shape — the local id + the (already envelope-stamped) event. `sig` is recomputed on load. */
|
|
154
159
|
type PersistedItem = { id: number; event: ClientEvent };
|
|
155
160
|
|
|
156
|
-
|
|
161
|
+
/**
|
|
162
|
+
* The verdict of a read that ran out of time. A DISTINCT value, and never `undefined` — an
|
|
163
|
+
* `undefined` here is byte-identical to "the adapter answered, there is no backlog", and that
|
|
164
|
+
* conflation destroys data silently: `parsePersisted(undefined)` gives `[]`, hydration takes its
|
|
165
|
+
* `length === 0` early return without throwing, and the next `persist()` writes the in-memory
|
|
166
|
+
* pending list over a blob nobody has read — or, with nothing pending, `removeItem`s it outright.
|
|
167
|
+
*/
|
|
168
|
+
const READ_TIMED_OUT: unique symbol = Symbol("wireai:storage-read-timeout");
|
|
169
|
+
|
|
170
|
+
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | typeof READ_TIMED_OUT> => {
|
|
157
171
|
let timer: ReturnType<typeof setTimeout>;
|
|
158
|
-
const timeout = new Promise<
|
|
159
|
-
timer = setTimeout(() => resolve(
|
|
172
|
+
const timeout = new Promise<typeof READ_TIMED_OUT>((resolve) => {
|
|
173
|
+
timer = setTimeout(() => resolve(READ_TIMED_OUT), ms);
|
|
160
174
|
});
|
|
161
175
|
return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
|
|
162
176
|
};
|
|
@@ -198,7 +212,7 @@ const parsePersisted = (raw: string | null | undefined): PersistedItem[] => {
|
|
|
198
212
|
export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
199
213
|
const target = options.target;
|
|
200
214
|
const storage = options.storage;
|
|
201
|
-
// One slot per QUEUE
|
|
215
|
+
// One slot per QUEUE: an explicit key is taken verbatim; the appId-derived default is rotated
|
|
202
216
|
// to `…#2` when a live queue already holds it, so two instances can never share one backlog.
|
|
203
217
|
//
|
|
204
218
|
// Only claimed when there IS storage. The defect is entirely about the persisted slot, and a
|
|
@@ -217,6 +231,13 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
217
231
|
let flushing = false;
|
|
218
232
|
let attempt = 0;
|
|
219
233
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
234
|
+
// The persisted blob exists but has NOT been read yet (the cold-start read blew its deadline and
|
|
235
|
+
// is still in flight). While this is true every write is suppressed: the only thing the queue
|
|
236
|
+
// could write is a view of `pending` that does not include the backlog it has not seen, and
|
|
237
|
+
// `setItem`/`removeItem` would destroy it. Cleared as soon as the slow read settles, whichever
|
|
238
|
+
// way it settles, so a read that ultimately fails resumes normal persistence rather than
|
|
239
|
+
// suppressing it for the life of the process.
|
|
240
|
+
let backlogUnread = false;
|
|
220
241
|
|
|
221
242
|
const resolveEnvelope = (): ContextEnvelope | undefined => {
|
|
222
243
|
try {
|
|
@@ -254,6 +275,8 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
254
275
|
|
|
255
276
|
const persist = (): void => {
|
|
256
277
|
if (!storage) return;
|
|
278
|
+
// NEVER write over a blob that has not been read yet. See `backlogUnread`.
|
|
279
|
+
if (backlogUnread) return;
|
|
257
280
|
try {
|
|
258
281
|
if (pending.length === 0) {
|
|
259
282
|
void storage.removeItem(key).catch(() => {});
|
|
@@ -280,25 +303,70 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
280
303
|
}
|
|
281
304
|
};
|
|
282
305
|
|
|
283
|
-
|
|
284
|
-
|
|
306
|
+
/**
|
|
307
|
+
* Merge a loaded backlog into the in-memory buffer: persisted (older) events go AHEAD of
|
|
308
|
+
* whatever was enqueued while the read was in flight, duplicates collapse, and the size cap
|
|
309
|
+
* applies as usual.
|
|
310
|
+
*
|
|
311
|
+
* Ids are minted FRESH from the running `nextId` rather than reset to 0. A drain may already be
|
|
312
|
+
* in flight holding a batch of ids it will filter out on ack, and re-numbering from 0 would make
|
|
313
|
+
* those ids point at different events — the ack would then dequeue (silently drop) whichever
|
|
314
|
+
* events happened to inherit them. Monotonic ids are never reused, so an in-flight ack stays
|
|
315
|
+
* correct whatever lands in between.
|
|
316
|
+
*/
|
|
317
|
+
const mergePersisted = (persistedItems: PersistedItem[]): void => {
|
|
318
|
+
if (persistedItems.length === 0) return;
|
|
319
|
+
const seen = new Set(pending.map((item) => item.sig));
|
|
320
|
+
const restored: QueuedItem[] = [];
|
|
321
|
+
for (const persisted of persistedItems) {
|
|
322
|
+
const sig = safeSig(persisted.event);
|
|
323
|
+
if (seen.has(sig)) continue; // already in memory — collapse the duplicate
|
|
324
|
+
seen.add(sig);
|
|
325
|
+
restored.push({ id: nextId++, event: persisted.event, sig });
|
|
326
|
+
}
|
|
327
|
+
if (restored.length === 0) return;
|
|
328
|
+
pending = [...restored, ...pending];
|
|
329
|
+
enforceSizeCap();
|
|
330
|
+
persist();
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Load any persisted backlog. Anything enqueued before this settles stays in memory; the merge
|
|
334
|
+
// above puts persisted (older) events ahead of it.
|
|
335
|
+
//
|
|
336
|
+
// A read that blows READ_TIMEOUT_MS is NOT treated as "no backlog". The race only unblocks the
|
|
337
|
+
// DRAIN — the original read is kept and merged whenever it lands, and until then every write is
|
|
338
|
+
// suppressed so the unread blob survives intact.
|
|
285
339
|
const loadPromise: Promise<void> = (async () => {
|
|
286
340
|
if (!storage) return;
|
|
287
341
|
try {
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
342
|
+
const read = Promise.resolve(storage.getItem(key));
|
|
343
|
+
// A rejecting read must not surface as an unhandled rejection when the race is won by the
|
|
344
|
+
// timeout; the recovery path below re-attaches its own handlers.
|
|
345
|
+
read.catch(() => {});
|
|
346
|
+
const raced = await withTimeout(read, READ_TIMEOUT_MS);
|
|
347
|
+
if (raced === READ_TIMED_OUT) {
|
|
348
|
+
backlogUnread = true;
|
|
349
|
+
warnInDev(
|
|
350
|
+
`[wireai] the persisted analytics backlog at "${key}" took longer than ${READ_TIMEOUT_MS}ms ` +
|
|
351
|
+
"to read, so the queue started without it. The stored events are NOT discarded: writes " +
|
|
352
|
+
"are held back until the read lands, and the backlog is merged in then. If you see this " +
|
|
353
|
+
"on every cold start, your storage adapter is too slow to be on the launch path.",
|
|
354
|
+
);
|
|
355
|
+
void read
|
|
356
|
+
.then((late) => {
|
|
357
|
+
backlogUnread = false;
|
|
358
|
+
mergePersisted(parsePersisted(late));
|
|
359
|
+
// Send whatever was just recovered; without this it would wait for the next enqueue.
|
|
360
|
+
flush();
|
|
361
|
+
})
|
|
362
|
+
.catch(() => {
|
|
363
|
+
// The slow read ultimately failed → nothing to preserve, resume normal persistence.
|
|
364
|
+
backlogUnread = false;
|
|
365
|
+
persist();
|
|
366
|
+
});
|
|
367
|
+
return;
|
|
299
368
|
}
|
|
300
|
-
|
|
301
|
-
persist();
|
|
369
|
+
mergePersisted(parsePersisted(raced));
|
|
302
370
|
} catch {
|
|
303
371
|
// Unreadable backlog → start empty; nothing enqueued in-memory is lost.
|
|
304
372
|
}
|
package/src/analytics/index.ts
CHANGED
|
@@ -40,6 +40,12 @@ export {
|
|
|
40
40
|
} from "./reportClientEvent";
|
|
41
41
|
export type { ClientEvent, ClientEventType, ClientEventTarget } from "./reportClientEvent";
|
|
42
42
|
|
|
43
|
+
// ─── Dev-only integration self-check (opt-in; a host that never calls it pays nothing) ─────────
|
|
44
|
+
// `readEventsAck` is deliberately NOT re-exported here: the doctor imports it from the transport
|
|
45
|
+
// module directly, so reuse costs nothing and the package's public surface stays as it was.
|
|
46
|
+
export { wireDoctor } from "./wireDoctor";
|
|
47
|
+
export type { WireDoctorCheck, WireDoctorReport, WireDoctorOptions } from "./wireDoctor";
|
|
48
|
+
|
|
43
49
|
// ─── Canonical onboarding funnel names + kit-event mapper ──────────────────────
|
|
44
50
|
export { WIRE_ONBOARDING_EVENTS, toAnalyticsEvent } from "./analyticsEvent";
|
|
45
51
|
export type { WireOnboardingEventName, AnalyticsEvent } from "./analyticsEvent";
|
|
@@ -122,10 +122,15 @@ export const makeSessionId = (): string =>
|
|
|
122
122
|
* `{written: 0, skipped: N, errors: [{index, reason: "validation_error", field: "ts"}]}` — every
|
|
123
123
|
* `app_event` from `track()`/`screen()` silently discarded behind a green response.
|
|
124
124
|
*
|
|
125
|
-
* WHY HERE and not at the queue's `stamp()`: this is the single choke point all
|
|
126
|
-
* through (offline queue, fire-and-forget, awaitable, session-start). Converting
|
|
127
|
-
* queue's numeric `ts` — and therefore its identical-JSON de-dup signature — exactly
|
|
128
|
-
* it also converts the persisted 0.13.0 backlogs (which hold a numeric `ts`) on their
|
|
125
|
+
* WHY HERE and not at the queue's `stamp()`: this is the single choke point all FIVE send paths go
|
|
126
|
+
* through (offline queue, fire-and-forget, awaitable, review transport, session-start). Converting
|
|
127
|
+
* here leaves the queue's numeric `ts` — and therefore its identical-JSON de-dup signature — exactly
|
|
128
|
+
* as it was, and it also converts the persisted 0.13.0 backlogs (which hold a numeric `ts`) on their
|
|
129
|
+
* way out.
|
|
130
|
+
*
|
|
131
|
+
* ⚠️ NOT the same list as the ack-consumer list in {@link warnOnSkippedEvents}, which is FOUR. The
|
|
132
|
+
* review transport builds its request here but fires and forgets without reading the response, so
|
|
133
|
+
* it rides this conversion and is absent from that one. Count the call sites before editing either.
|
|
129
134
|
*
|
|
130
135
|
* NEVER mutates the caller's event: an event that needs a change is copied. A string `ts` (a
|
|
131
136
|
* caller-set ISO stamp) and an absent `ts` pass through untouched. A non-finite (`NaN`/`Infinity`)
|
|
@@ -154,17 +159,30 @@ const toWireEvents = (events: ClientEvent[]): ClientEvent[] =>
|
|
|
154
159
|
*
|
|
155
160
|
* It is also where the internal epoch-ms `ts` becomes the wire's ISO8601 string — see
|
|
156
161
|
* {@link toWireEvents} for why the conversion belongs at this choke point.
|
|
162
|
+
*
|
|
163
|
+
* `options.dryRun` asks the server to VALIDATE the batch and write nothing, which is what the wire
|
|
164
|
+
* doctor's round-trip check needs: a real POST, through this one builder, that cannot pollute a
|
|
165
|
+
* tenant's funnel. The `dry_run` key is added ONLY on an explicit `true`, so every production
|
|
166
|
+
* caller (which passes no options at all) still serializes the exact same bytes it did before the
|
|
167
|
+
* parameter existed. That byte-identity is asserted in `buildEventsRequest.test.ts`; every shipped
|
|
168
|
+
* send path rides this envelope, so a diagnostic is not allowed to change it for them.
|
|
157
169
|
*/
|
|
158
170
|
export const buildEventsRequest = (
|
|
159
171
|
target: { serverUrl: string; apiKey?: string } | undefined,
|
|
160
172
|
events: ClientEvent[],
|
|
173
|
+
options?: { dryRun?: boolean },
|
|
161
174
|
): { url: string; init: RequestInit } | null => {
|
|
162
175
|
if (!target?.serverUrl || events.length === 0) return null;
|
|
163
176
|
try {
|
|
164
177
|
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
165
178
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
166
179
|
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
167
|
-
|
|
180
|
+
// The key is CONDITIONALLY assigned rather than set to a falsy default: an absent property and
|
|
181
|
+
// a `dry_run: false` property do not serialize the same, and only absence keeps the body byte
|
|
182
|
+
// identical for the production call sites.
|
|
183
|
+
const envelope: { events: ClientEvent[]; dry_run?: true } = { events: toWireEvents(events) };
|
|
184
|
+
if (options?.dryRun === true) envelope.dry_run = true;
|
|
185
|
+
const body = JSON.stringify(envelope);
|
|
168
186
|
return { url, init: { method: "POST", headers, body } };
|
|
169
187
|
} catch {
|
|
170
188
|
// URL construction or JSON serialization failed — nothing to send.
|
|
@@ -176,7 +194,7 @@ export const buildEventsRequest = (
|
|
|
176
194
|
* each entry carrying `{index, reason, field}` — `field` names the property that failed validation
|
|
177
195
|
* (it is what identified the `ts` rejection), so it is folded into the reason string here. Still
|
|
178
196
|
* read defensively: an older server sends no `errors` at all. */
|
|
179
|
-
type EventsAck = { written?: number; skipped: number; reasons: string[] };
|
|
197
|
+
export type EventsAck = { written?: number; skipped: number; reasons: string[] };
|
|
180
198
|
|
|
181
199
|
/**
|
|
182
200
|
* Read the `/v1/events` ACK body. Resolves `undefined` when there is nothing readable — no `.json`
|
|
@@ -186,8 +204,12 @@ type EventsAck = { written?: number; skipped: number; reasons: string[] };
|
|
|
186
204
|
*
|
|
187
205
|
* ⚠️ The body can be read exactly once, so a caller that both decides on `skipped` AND warns must do
|
|
188
206
|
* both from ONE call to this.
|
|
207
|
+
*
|
|
208
|
+
* Exported from this MODULE (not from the `./analytics` barrel) so the wire doctor can read an ack
|
|
209
|
+
* with the same reader the send paths use. A second ack reader would be a second thing to drift,
|
|
210
|
+
* and the drift it exists to catch is exactly the kind that hides behind a 200.
|
|
189
211
|
*/
|
|
190
|
-
const readEventsAck = async (res: unknown): Promise<EventsAck | undefined> => {
|
|
212
|
+
export const readEventsAck = async (res: unknown): Promise<EventsAck | undefined> => {
|
|
191
213
|
try {
|
|
192
214
|
const json = (res as { json?: () => Promise<unknown> } | null | undefined)?.json;
|
|
193
215
|
if (typeof json !== "function") return undefined;
|