@wireai/activation 0.12.2 → 0.13.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/README.md +5 -3
  3. package/dist/analytics/index.d.mts +2 -2
  4. package/dist/analytics/index.d.ts +2 -2
  5. package/dist/analytics/index.js +114 -35
  6. package/dist/analytics/index.js.map +1 -1
  7. package/dist/analytics/index.mjs +114 -36
  8. package/dist/analytics/index.mjs.map +1 -1
  9. package/dist/{currentSession-BxEB37xt.d.ts → currentSession-D7zabMXK.d.ts} +161 -9
  10. package/dist/{currentSession-BlCeDP0f.d.mts → currentSession-_GynvhzT.d.mts} +161 -9
  11. package/dist/index.d.mts +5 -15
  12. package/dist/index.d.ts +5 -15
  13. package/dist/index.js +232 -135
  14. package/dist/index.js.map +1 -1
  15. package/dist/index.mjs +229 -134
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/questionnaire/index.js.map +1 -1
  18. package/dist/questionnaire/index.mjs.map +1 -1
  19. package/dist/reviews/index.js +8 -6
  20. package/dist/reviews/index.js.map +1 -1
  21. package/dist/reviews/index.mjs +8 -6
  22. package/dist/reviews/index.mjs.map +1 -1
  23. package/package.json +1 -1
  24. package/src/OnboardingFlow.tsx +10 -3
  25. package/src/WireOnboarding.tsx +115 -32
  26. package/src/activation/wireActivation.ts +13 -7
  27. package/src/analytics/analyticsFacade.ts +11 -10
  28. package/src/analytics/currentSession.ts +6 -20
  29. package/src/analytics/eventQueue.ts +69 -1
  30. package/src/analytics/index.ts +1 -1
  31. package/src/analytics/reportClientEvent.ts +92 -29
  32. package/src/config/wireConfigFromEnv.ts +1 -10
  33. package/src/context/deviceId.ts +77 -16
  34. package/src/context/userContext.ts +4 -15
  35. package/src/identity/identityRecord.ts +123 -0
  36. package/src/identity/userIdentity.ts +45 -9
  37. package/src/index.ts +6 -4
  38. package/src/session-analytics/useLifecycleEvents.ts +10 -1
  39. package/src/types.ts +14 -0
  40. package/src/utils/deriveAnswers.ts +6 -2
  41. package/src/utils/readProgress.ts +4 -0
  42. package/src/utils/warnInDev.ts +33 -0
  43. package/src/components/DoneBlock.tsx +0 -37
package/CHANGELOG.md CHANGED
@@ -3,6 +3,151 @@
3
3
  All notable changes to `@wireai/activation` (formerly `wireai-onboarding`).
4
4
  Historical entries below the rename keep the old package name on purpose.
5
5
 
6
+ ## [0.13.0] — 2026-07-27
7
+
8
+ The identity-provenance release. Every id-layer defect fixed here is one omission wearing five faces:
9
+ `session_id` and `device_key` are bare `string`s minted independently by four subsystems, and nothing
10
+ anywhere recorded WHERE a given id came from. So a rejecting storage adapter's in-memory id was
11
+ indistinguishable from a persisted one, an app-OPEN session id could be posted into a field meaning the
12
+ ONBOARDING session, an auto-minted key could be injected beside a device id the host demonstrably owned
13
+ on another surface, and the join key could change identity mid-mount — each of them silently, each of
14
+ them returning success. This release gives ids provenance and teaches every consumer to refuse a
15
+ fabricated one.
16
+
17
+ Nothing here changes the wire for a correctly-wired host. The behaviour changes all move in one
18
+ direction: the kit now declines and says so, where it used to fabricate and return `true`.
19
+
20
+ ### Added
21
+
22
+ - **`resolveIdentity(...)`, `hostIdentity(...)` and the `IdentityRecord` type** (`identity/identityRecord.ts`) —
23
+ the substrate. One record, `{ value, space, source, durable }`, parked on the same `Symbol.for`
24
+ registry pattern the repo already uses for the current session id, the auto device key and activation
25
+ revalidation, so every inlined bundle copy addresses ONE registry. `space` is
26
+ `onboarding-session | app-session | device`, `source` is `host | auto`, and `durable` says whether the
27
+ value was actually persisted. A host-sourced record is recorded on a process-wide registry, which is
28
+ what lets one surface notice that another surface owns a device key it was not given.
29
+ ⚠️ Deliberately NOT a branded-types refactor — `OnboardingSessionId` / `AppSessionId` / `DeviceKey`
30
+ across every signature is real value and is deferred, because it touches every file.
31
+ - **`hydrateDeviceIdentity(...)`** (`context/deviceId.ts`) — the provenance-carrying sibling of
32
+ `hydrateAutoDeviceKey`. Same awaited read, but it answers "is this an id this install will KEEP?"
33
+ instead of only "what is the id?". `hydrateAutoDeviceKey` is unchanged and still returns a bare string.
34
+ - **`OnboardingProgress.slot_id`** — an optional STABLE per-slot identity for a screen, read off
35
+ `props.progress` by `readProgress` and preferred over `key` by `deriveAnswers`. `key` is authored from
36
+ the question's prompt text (tenant flows slugify it and cut at 32 chars), so re-wording a question mints
37
+ a NEW key and a host reading `answers.interests` silently starts reading `undefined`.
38
+ ⚠️ **INERT UNTIL THE SERVER EMITS IT.** No deployed server sends `slot_id` today. Against a server that
39
+ never sends it, every path degrades to byte-identical 0.12.2 behaviour. Every fallback is decided
40
+ PER CARD, never cached as a per-session "this backend supports slots" verdict, so a thread that mixes
41
+ slotted and unslotted cards — the real shape during a rollout — keys each card correctly.
42
+ - **`allowAppSessionFallback` on `identifyOnboarding`** — the opt-in switch for the tier-3 behaviour
43
+ described under Changed. Default `false`.
44
+ - **`resetEventQueueKeys()`** (`@wireai/activation/analytics`, test-only) — clears
45
+ the claimed-queue-key registry. A real relaunch is a new process; a test that simulates one in-process
46
+ needs this or its second queue reads as a concurrent sibling.
47
+ - **A first test file for `deriveAnswers`** (`utils/deriveAnswers.test.ts`), table-driven over real thread
48
+ shapes: progress key present and absent, slugified prose, multi-select, every NL wrapper the SDK
49
+ produces, and the threads that must produce NO entry.
50
+
51
+ ### Changed
52
+
53
+ - **⚠️ BEHAVIOR CHANGE: a `storage` adapter that FAILS no longer earns an injected join key.** The
54
+ auto-join gate was a bare presence check on the `storage` prop — never the SUCCESS of the write — and
55
+ `hydrateAutoDeviceKey` resolves to the in-memory mint on every failure branch, so a REJECTING adapter
56
+ (a locked / full / permission-denied AsyncStorage, the most common real breakage) injected a FRESH
57
+ `wdev_*` on every launch. Probe-verified: two launches, two different keys. That is strictly worse than
58
+ injecting nothing — the server counts `min_sessions` by distinct opens grouped on `device_key`, so a
59
+ per-launch key makes that counter structurally incapable of exceeding 1 AND inflates distinct-device
60
+ counts. The gate now reads `IdentityRecord.durable`, true only when the id was adopted from storage or
61
+ written to it successfully, so a broken adapter lands on the same "declined, and here is why" path as
62
+ no adapter at all. The existing dev warning was widened to name this third reason.
63
+ Who this changes: a host whose storage was silently broken. It was already getting corrupt data; it now
64
+ gets no join key and a warning that names the cause.
65
+ - **⚠️ BEHAVIOR CHANGE: `identifyOnboarding` no longer binds to the app-open session by default, and its
66
+ return type widened to `"onboarding" | "app_session" | false`.** Tier 3 fell back to
67
+ `getCurrentSessionId()` — the per-OPEN session — and posted it in the `session_id` field, which on this
68
+ endpoint means the ONBOARDING session, then returned `true`. Two disjoint id spaces share that field, so
69
+ the row it wrote could never join the onboarding funnel while the host was told it had worked. It fired
70
+ in exactly the DOCUMENTED post-completion case, because completion clears the persisted session. The
71
+ fallback is now behind `allowAppSessionFallback` and, when it does fire, says so in the return value.
72
+ ⚠️ `"onboarding"` is truthy, so `if (await identifyOnboarding(...))` behaves identically at runtime;
73
+ only an explicit `: boolean` annotation needs updating.
74
+ - **⚠️ BEHAVIOR CHANGE: `wire.track()` returns `false` for an event the server DISCARDED.**
75
+ `POST /v1/events` answers HTTP 200 with `{ ok, written, skipped }` and counts a refused event in
76
+ `skipped`, so `reportClientEventsAwait` — which read `res.ok` alone — resolved `true` for an event that
77
+ was thrown away, and `wire.track` then bumped decision revalidation, making every subscribed gate
78
+ re-fetch against a stream the action never entered. It now reads the ack. Backward compatible by
79
+ construction: ONLY an explicit positive `skipped` demotes a 200, so an old server that sends no such
80
+ field, an unparseable body, or a response with no `.json` at all still resolves `true` — the change can
81
+ produce no false negatives.
82
+ - **The join decision is FROZEN at the moment the loader gate opens.** `userContext` feeds the `llm` memo
83
+ and wireai-rn recreates its A2A adapter whenever that identity changes, resetting `contextId`. So a host
84
+ resolving its device key ASYNCHRONOUSLY — the default shape of any host reading its id out of async
85
+ storage — rendered once without it, got `wdev_*` injected, then had the real key land a tick later and
86
+ rebuild the adapter, DROPPING the server-learned session id and RESTARTING the user's onboarding
87
+ mid-flow with an orphaned session left behind. The session-start metadata now reads a frozen snapshot,
88
+ exactly as `startupUserIdRef` already did for the user id. Client events deliberately keep reading the
89
+ LIVE context: they carry no adapter, so late host context enriches them at no risk.
90
+ - **A host device key on ANOTHER surface is no longer silent.** 0.12.2 suppressed the missing-join-key
91
+ warning whenever injection succeeded — right when the host genuinely owns no device id, wrong when it
92
+ owns one and forgot it on this mount. That host went from a loud warning to a silent THIRD id space. Any
93
+ surface constructed with a host-supplied device key (`createAnalytics`, `createWireActivation`,
94
+ `useLifecycleEvents`, `<WireOnboarding userContext={...}>`) now records it, scoped per `appId`, and a mount
95
+ that injected its own key while the process demonstrably owns a host one warns and prints both. It warns
96
+ rather than declining, because declining would leave the onboarding side with no key at all; it does not
97
+ silently ADOPT the other key, because that is the kit guessing which of two ids a host meant on the
98
+ strength of construction order.
99
+ - **Two event queues for one `appId` no longer share one storage slot.** The default key was derived from
100
+ `appId` alone, so two `createAnalytics` instances for one tenant — the documented double-wiring — shared
101
+ ONE persisted backlog while keeping SEPARATE in-memory buffers: each `persist()` overwrote the other's
102
+ blob, a queue draining to empty called `removeItem` and DELETED a sibling's still-pending events, and on
103
+ relaunch the survivors were loaded by both and sent twice. The second claimant of a default key now gets
104
+ `…#2` and a dev warning naming the fix. Only the appId-derived DEFAULT is ever rotated — an EXPLICIT
105
+ `storageKey` is a host declaring which slot it owns (`useLifecycleEvents` does exactly that and
106
+ re-creates its queue on every remount, where rotation would be a worse bug). Only claimed when there IS
107
+ storage; a storage-less queue never touches the key.
108
+ - **A failed device-key hydration is retried instead of cached for the process lifetime.** A transient
109
+ cold-boot storage lock used to be permanent: the settled failure stayed parked on the registry and every
110
+ later caller joined it. A degraded outcome now releases its latches so the next caller starts a fresh read.
111
+ - **The DISCARDED warning covers all FOUR send paths and stops naming a cause the kit never checked.** It
112
+ used to end "the usual cause is an event with a missing or empty `session_id`" — but since
113
+ `ensureCurrentSessionId` shipped, no kit path can emit an event without one, making that the LEAST likely
114
+ explanation and sending every reader to the one place the problem is not. It now reports what the server
115
+ actually said (`skipped`, `written`, and any per-event `reasons` once the server sends them) and admits
116
+ when the endpoint gave no reason. The two `reportClientEvent*` paths — which carry `dropped`, `identify`,
117
+ `client_fallback` and every `wire.track` — join the offline queue and session-start in consuming the ack.
118
+ - **`validators` are looked up by `slot_id` first, then `progress.key`.** Host validators live in the SAME
119
+ key namespace `deriveAnswers` files answers under, so moving the answer key to the slot without moving
120
+ this lookup would have silently UNBOUND every host validator: no error, no warning, values simply stop
121
+ being checked. The fallback keeps a validator map written against today's keys working while a server
122
+ rolls slots out.
123
+
124
+ ### Removed
125
+
126
+ - **`DoneBlock`** — self-`@deprecated` since 0.1.x, unused internally since the terminal screen became
127
+ `CompletionView`, and referenced by nothing in `src/` or the playground. The component file is deleted.
128
+ - **`RESERVED_USER_CONTEXT_KEYS`** — read by nothing, including its own module: the namespacing it claims
129
+ to govern is enforced by `EXTRA_KEY_PREFIX`. It was a documentation constant shaped like an invariant.
130
+
131
+ ### Internal
132
+
133
+ - The FOUR duplicate `warnInDev` copies (`WireOnboarding`, `config/wireConfigFromEnv`,
134
+ `analytics/analyticsFacade`, `analytics/currentSession`) collapse into `utils/warnInDev`, which returns
135
+ whether it ACTUALLY warned — the property `currentSession`'s once-flag depends on, and the only reason
136
+ its copy differed. The stated rationale for that copy (keeping the module import-free for the
137
+ tree-shaken analytics bundle) had already lapsed: it imports `makeSessionId` from a sibling. The new
138
+ module has no imports of its own, and the `treeShake` canary still holds the real guarantee.
139
+ - The two byte-identical trim helpers (`context/userContext.cleanString`, `activation/wireActivation.clean`)
140
+ collapse into one shared `cleanString`, not re-exported from the barrel.
141
+ - **The canary suite can observe a payload for the first time.** Every `onComplete` mock was typed with
142
+ ZERO parameters, so `OnboardingResult` was structurally unobservable and every assertion could only be a
143
+ call count — the mechanism behind "500 green tests, zero data". `flow.test.tsx` now captures the argument
144
+ and asserts the answers map end to end. The scripted `wireai-rn` mock also appends the USER turn to the
145
+ thread, as the real SDK does; without it the mocked thread was assistant-only, so `deriveAnswers` had
146
+ nothing to pair and `answers` was always `{}` under test regardless of the implementation.
147
+ - `mountCanary` gained `rerender()`, the seam for a prop that arrives LATE. The churn loop runs entirely
148
+ inside the initial `act`, so without it there was no way to re-render a mount after an async gate had
149
+ settled — and the whole "value changed mid-flow" defect class was untestable.
150
+
6
151
  ## [0.12.2] — 2026-07-27
7
152
 
8
153
  The auto-join patch. 0.12.1 taught the kit to complain about a missing join key; this one teaches it
package/README.md CHANGED
@@ -234,7 +234,7 @@ import { WireOnboarding } from "@wireai/activation";
234
234
  | `onComplete` | `(result: { answers, raw }) => void` | Fires when the user taps the terminal recap's CTA. **Required.** |
235
235
  | `theme` | `Partial<OnboardingTheme>` | Brand colors/fonts/radius/spacing, deep-merged over a neutral default. |
236
236
  | `illustrations` | `Record<string, ReactNode>` | App artwork for `InterstitialCard`, keyed by name. |
237
- | `validators` | `Record<string, StepValidator>` | Per-step, keyed by base-question key (e.g. `username`). Blocks advance + shows an inline error. |
237
+ | `validators` | `Record<string, StepValidator>` | Per-step, keyed by base-question key (e.g. `username`). Blocks advance + shows an inline error. When the backend sends a `progress.slot_id` for a screen, the kit looks up that slot FIRST and falls back to the question key, so a validator map written against today's keys keeps working. |
238
238
  | `onSkip` | `() => void` | User skipped. |
239
239
  | `onError` | `(err) => void` | Backend error/timeout — host owns recovery (e.g. route to a static flow). Without it, the kit shows an inline retry. |
240
240
  | `onEvent` | `(e: OnboardingEvent) => void` | `started` / `turn` / `error` — recover per-turn analytics since the kit owns the loop. |
@@ -247,7 +247,7 @@ import { WireOnboarding } from "@wireai/activation";
247
247
  | `sessionTtlMs` | `number` | How long a persisted session id stays resumable. Default `3600000` (1h, the backend's session TTL). With `storage` only. |
248
248
  | `persistKey` | `string` | Override the storage key (default `wireai:session:<appId>`). Scope per-user if one device can onboard multiple accounts mid-flow. With `storage` only. |
249
249
  | `userContext` | `Record<string, string \| number \| boolean>` | **This is where the join key goes.** `user_context.device_key` is the only thing that joins this onboarding session to the app's later events, so pass `userContext={activationJoinContext(deviceKey)}` (or `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))` if your app owns no device id). Since 0.12.2, omitting it no longer empties the funnel silently: with `storage`, the kit injects its own key (see [The join key](#the-join-key-device_key-never-session_id) and `autoJoinKey` below). Second job: any other non-PII context the app already knows (signup method, referral, plan, a HASHED user id), forwarded on session metadata + client events for funnel segmentation. **No PII on THIS prop** (no raw emails/names); primitives only; the server caps size/keys. See [Device & user context](#device--user-context). ⚠️ Distinct from the analytics/activation **`WireUserContext`** object (`createAnalytics` / `useWireActivation`), which additionally accepts an opt-in raw `userEmail` — see [Rich user context & PII](#rich-user-context--pii). |
250
- | `autoJoinKey` | `boolean` | Opt OUT of the automatic join key. Default `true`. When this prop is left alone, `storage` is present, and `userContext` carries no `device_key`, the kit injects its own per-install key (the same id `createAnalytics` / `createWireActivation` mint and persist), so the `activated` funnel joins with no host wiring. Pass `autoJoinKey={false}` if you genuinely want an UNLINKED onboarding session: that restores the pre-0.12.2 behavior exactly, and the dev warning fires again. It never overrides a `device_key` you passed, and it cannot inject without `storage` (see [The join key](#the-join-key-device_key-never-session_id)). |
250
+ | `autoJoinKey` | `boolean` | Opt OUT of the automatic join key. Default `true`. When this prop is left alone, `storage` is present **and actually persists** (0.13.0: a `storage` adapter that rejects or throws is treated exactly like no `storage` — the kit declines and says so, because a key it cannot persist is a different key on every launch, which corrupts `min_sessions` rather than merely leaving the join empty), and `userContext` carries no `device_key`, the kit injects its own per-install key (the same id `createAnalytics` / `createWireActivation` mint and persist), so the `activated` funnel joins with no host wiring. Pass `autoJoinKey={false}` if you genuinely want an UNLINKED onboarding session: that restores the pre-0.12.2 behavior exactly, and the dev warning fires again. It never overrides a `device_key` you passed, and it cannot inject without `storage` (see [The join key](#the-join-key-device_key-never-session_id)). |
251
251
  | `userId` | `string` | Your own OPAQUE user id, so onboarding sessions reconcile to real users later (console sessions to your user table / GA4 users). Optional and supports **late binding**: present at mount it rides the session-start metadata; if it changes mid-session (the user just registered) the kit emits an `identify` event; available only after the flow, use `identifyOnboarding(...)`. **No PII** (not an email/name/phone); trimmed and capped at 128 chars. See [User identity](#user-identity). |
252
252
 
253
253
  ## Helpers (the reusable substrate)
@@ -340,7 +340,9 @@ const contextIdRef = useRef<string>();
340
340
  await identifyOnboarding({ config, userId: newUser.id, contextId: contextIdRef.current });
341
341
  ```
342
342
 
343
- If you passed `storage` to `<WireOnboarding>`, you can omit `contextId` and hand `identifyOnboarding` the same `storage` (plus `appId` or `persistKey`) and it recovers the persisted session id itself. That works while the session is still persisted (before completion clears it), so the captured-`contextId` path above is the safe one post-completion. `identifyOnboarding` is fire-and-forget and never throws; it resolves `true` when it dispatched an `identify` and `false` when it could not (no user id, no server url, or no resolvable session).
343
+ If you passed `storage` to `<WireOnboarding>`, you can omit `contextId` and hand `identifyOnboarding` the same `storage` (plus `appId` or `persistKey`) and it recovers the persisted session id itself. That works while the session is still persisted (before completion clears it), so the captured-`contextId` path above is the safe one post-completion. `identifyOnboarding` is fire-and-forget and never throws. Since 0.13.0 it resolves the id SPACE it bound rather than a bare boolean: `"onboarding"` when it attached to the A2A `contextId` (the one that attributes the funnel), `"app_session"` when you opted into `allowAppSessionFallback` and it fell back to the live per-open app session, and `false` when it could not bind at all (no user id, no server url, or no resolvable session). `"onboarding"` is truthy, so an existing `if (await identifyOnboarding(...))` behaves identically.
344
+
345
+ ⚠️ **The app-session fallback is now opt-in and off by default.** Before 0.13.0 it ran unconditionally: with no captured `contextId` it posted the per-OPEN session id in the `session_id` field — which on this endpoint means the ONBOARDING session — and returned `true`. Those are two different id spaces sharing one wire field, so the row it wrote could never join the onboarding funnel while the caller was told it had worked.
344
346
 
345
347
  **No PII.** The onboarding `userId` prop is length-bound only; the kit cannot detect an email for you. Keep it opaque, the same rule as the onboarding `userContext` prop.
346
348
 
@@ -1,6 +1,6 @@
1
1
  export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-CF_eHwzC.mjs';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-BlCeDP0f.mjs';
3
- export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resolveAutoDeviceKey, F as setCurrentSessionId, G as toAnalyticsEvent } from '../currentSession-BlCeDP0f.mjs';
2
+ import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-_GynvhzT.mjs';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resetEventQueueKeys, F as resolveAutoDeviceKey, G as setCurrentSessionId, H as toAnalyticsEvent } from '../currentSession-_GynvhzT.mjs';
4
4
  import '../types-CNUqMK0D.mjs';
5
5
  import '../types-BKfpdZzX.mjs';
6
6
  import '../types-BcmagF6K.mjs';
@@ -1,6 +1,6 @@
1
1
  export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-DsRe4epC.js';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-BxEB37xt.js';
3
- export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resolveAutoDeviceKey, F as setCurrentSessionId, G as toAnalyticsEvent } from '../currentSession-BxEB37xt.js';
2
+ import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-D7zabMXK.js';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resetEventQueueKeys, F as resolveAutoDeviceKey, G as setCurrentSessionId, H as toAnalyticsEvent } from '../currentSession-D7zabMXK.js';
4
4
  import '../types-Buj9Lw9t.js';
5
5
  import '../types-BKfpdZzX.js';
6
6
  import '../types-BcmagF6K.js';
@@ -413,28 +413,35 @@ var buildEventsRequest = (target, events) => {
413
413
  return null;
414
414
  }
415
415
  };
416
- var warnOnSkippedEvents = (res) => {
417
- if (typeof __DEV__ === "undefined" || !__DEV__) return;
418
- if (typeof console === "undefined" || !console.warn) return;
416
+ var readEventsAck = async (res) => {
419
417
  try {
420
418
  const json = res == null ? void 0 : res.json;
421
- if (typeof json !== "function") return;
422
- void Promise.resolve(json.call(res)).then((body) => {
423
- const skipped = body == null ? void 0 : body.skipped;
424
- if (typeof skipped !== "number" || skipped <= 0) return;
425
- console.warn(
426
- `[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${skipped} event(s) (skipped in the response body) \u2014 they are gone, not retried. The usual cause is an event with a missing or empty session_id.`
427
- );
428
- }).catch(() => {
429
- });
419
+ if (typeof json !== "function") return void 0;
420
+ const body = await Promise.resolve(json.call(res));
421
+ const skipped = body == null ? void 0 : body.skipped;
422
+ if (typeof skipped !== "number" || !Number.isFinite(skipped)) return void 0;
423
+ const reasons = Array.isArray(body == null ? void 0 : body.errors) ? body.errors.map((e) => e == null ? void 0 : e.reason).filter((r) => typeof r === "string") : [];
424
+ return { written: typeof (body == null ? void 0 : body.written) === "number" ? body.written : void 0, skipped, reasons };
430
425
  } catch {
426
+ return void 0;
431
427
  }
432
428
  };
429
+ var describeDiscarded = (ack) => `[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${ack.skipped} event(s) (skipped in the response body) \u2014 they are gone, not retried. The server reported: skipped=${ack.skipped}${ack.written !== void 0 ? `, written=${ack.written}` : ""}` + (ack.reasons.length > 0 ? `, reasons: ${ack.reasons.join(", ")}.` : ". It gave no reason (the endpoint folds every rejection into one count), so check the server's ingest log for this request rather than guessing.");
430
+ var warnOnSkippedEvents = (res) => {
431
+ if (typeof __DEV__ === "undefined" || !__DEV__) return;
432
+ if (typeof console === "undefined" || !console.warn) return;
433
+ void readEventsAck(res).then((ack) => {
434
+ if (!ack || ack.skipped <= 0) return;
435
+ console.warn(describeDiscarded(ack));
436
+ });
437
+ };
433
438
  var reportClientEvents = (target, events) => {
434
439
  try {
435
440
  const req = buildEventsRequest(target, events);
436
441
  if (!req) return;
437
- void fetch(req.url, req.init).catch(() => {
442
+ void fetch(req.url, req.init).then((res) => {
443
+ warnOnSkippedEvents(res);
444
+ }).catch(() => {
438
445
  });
439
446
  } catch {
440
447
  }
@@ -445,13 +452,28 @@ var reportClientEventsAwait = async (target, events) => {
445
452
  const req = buildEventsRequest(target, events);
446
453
  if (!req) return false;
447
454
  const res = await fetch(req.url, req.init);
448
- return Boolean(res && res.ok);
455
+ if (!res || !res.ok) return false;
456
+ const ack = await readEventsAck(res);
457
+ if (!ack || ack.skipped <= 0) return true;
458
+ if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
459
+ console.warn(describeDiscarded(ack));
460
+ }
461
+ return false;
449
462
  } catch {
450
463
  return false;
451
464
  }
452
465
  };
453
466
  var reportClientEventAwait = (target, event) => reportClientEventsAwait(target, [event]);
454
467
 
468
+ // src/utils/warnInDev.ts
469
+ var warnInDev = (message) => {
470
+ if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
471
+ console.warn(message);
472
+ return true;
473
+ }
474
+ return false;
475
+ };
476
+
455
477
  // src/analytics/currentSession.ts
456
478
  var CURRENT_SESSION_ID_SLOT = /* @__PURE__ */ Symbol.for(
457
479
  "@wireai/activation:currentSessionId"
@@ -466,13 +488,6 @@ var getCurrentSessionId = () => globalSlot[CURRENT_SESSION_ID_SLOT];
466
488
  var resetCurrentSessionId = () => {
467
489
  globalSlot[CURRENT_SESSION_ID_SLOT] = void 0;
468
490
  };
469
- var warnInDev = (message) => {
470
- if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
471
- console.warn(message);
472
- return true;
473
- }
474
- return false;
475
- };
476
491
  var MINT_WARNING = "[wireai] No app-open session was registered, so a session id was minted for this event (the server drops an event that has no session_id, and still answers 200). Mount useLifecycleEvents at your app root so events correlate to a real app-open.";
477
492
  var MINT_WARNED_SLOT = /* @__PURE__ */ Symbol.for(
478
493
  "@wireai/activation:currentSessionIdMintWarned"
@@ -820,6 +835,31 @@ var DEFAULTS = {
820
835
  maxRetries: 6
821
836
  };
822
837
  var READ_TIMEOUT_MS = 1500;
838
+ var QUEUE_KEY_SLOT = /* @__PURE__ */ Symbol.for("@wireai/activation:eventQueueKeys");
839
+ var queueKeyGlobal = globalThis;
840
+ var claimedQueueKeys = () => {
841
+ const existing = queueKeyGlobal[QUEUE_KEY_SLOT];
842
+ if (existing) return existing;
843
+ const created = /* @__PURE__ */ new Set();
844
+ queueKeyGlobal[QUEUE_KEY_SLOT] = created;
845
+ return created;
846
+ };
847
+ var claimQueueKey = (preferred, explicit) => {
848
+ const claimed = claimedQueueKeys();
849
+ if (explicit || !claimed.has(preferred)) {
850
+ claimed.add(preferred);
851
+ return preferred;
852
+ }
853
+ let ordinal = 2;
854
+ while (claimed.has(`${preferred}#${ordinal}`)) ordinal++;
855
+ const key = `${preferred}#${ordinal}`;
856
+ claimed.add(key);
857
+ warnInDev(
858
+ `[wireai] a second event queue was created for the same appId, and "${preferred}" is already claimed by a live one. Two queues sharing one storage slot overwrite each other's backlog, delete each other's pending events when one drains to empty, and double-send on relaunch, so this queue was given "${key}" instead. Prefer ONE analytics instance per app; if you really need two, pass an explicit \`storageKey\` to each so the slots are yours to reason about.`
859
+ );
860
+ return key;
861
+ };
862
+ var resetEventQueueKeys = () => claimedQueueKeys().clear();
823
863
  var withTimeout = (p, ms) => {
824
864
  let timer;
825
865
  const timeout = new Promise((resolve) => {
@@ -851,7 +891,8 @@ var createEventQueue = (options) => {
851
891
  var _a2, _b, _c, _d, _e, _f, _g;
852
892
  const target = options.target;
853
893
  const storage = options.storage;
854
- const key = (_b = options.storageKey) != null ? _b : `wireai:evtq:${(_a2 = options.appId) != null ? _a2 : "default"}`;
894
+ const defaultKey = (_b = options.storageKey) != null ? _b : `wireai:evtq:${(_a2 = options.appId) != null ? _a2 : "default"}`;
895
+ const key = storage ? claimQueueKey(defaultKey, options.storageKey !== void 0) : defaultKey;
855
896
  const maxSize = (_c = options.maxSize) != null ? _c : DEFAULTS.maxSize;
856
897
  const batchSize = (_d = options.batchSize) != null ? _d : DEFAULTS.batchSize;
857
898
  const baseBackoffMs = (_e = options.baseBackoffMs) != null ? _e : DEFAULTS.baseBackoffMs;
@@ -1102,6 +1143,29 @@ var resolveUserContext = (ctx = {}, opts = {}) => {
1102
1143
  return result;
1103
1144
  };
1104
1145
 
1146
+ // src/identity/identityRecord.ts
1147
+ var IDENTITY_PROVENANCE_SLOT = /* @__PURE__ */ Symbol.for("@wireai/activation:identityProvenance");
1148
+ var provenanceGlobal = globalThis;
1149
+ var provenanceRegistry = () => {
1150
+ const existing = provenanceGlobal[IDENTITY_PROVENANCE_SLOT];
1151
+ if (existing) return existing;
1152
+ const created = { host: /* @__PURE__ */ new Map() };
1153
+ provenanceGlobal[IDENTITY_PROVENANCE_SLOT] = created;
1154
+ return created;
1155
+ };
1156
+ var provenanceKey = (space, scope) => `${space}:${scope != null ? scope : "default"}`;
1157
+ var resolveIdentity = (input) => {
1158
+ var _a2;
1159
+ if (typeof input.value !== "string") return void 0;
1160
+ const value = input.value.trim();
1161
+ if (!value) return void 0;
1162
+ const durable = (_a2 = input.durable) != null ? _a2 : input.source === "host";
1163
+ {
1164
+ provenanceRegistry().host.set(provenanceKey(input.space, input.scope), value);
1165
+ }
1166
+ return { value, space: input.space, source: input.source, durable };
1167
+ };
1168
+
1105
1169
  // src/context/deviceId.ts
1106
1170
  var AUTO_DEVICE_ID_PREFIX = "wdev_";
1107
1171
  var deviceIdStorageKey = (appId) => `wireai:analytics:deviceKey:${appId != null ? appId : "default"}`;
@@ -1124,24 +1188,37 @@ var startHydration = (registry, appId, storage, minted) => {
1124
1188
  const existing = registry.pending.get(appId);
1125
1189
  if (existing) return existing;
1126
1190
  const slot = deviceIdStorageKey(appId);
1127
- const settled = () => {
1191
+ const degraded = () => {
1128
1192
  var _a2;
1129
- return (_a2 = registry.keys.get(appId)) != null ? _a2 : minted;
1193
+ return { value: (_a2 = registry.keys.get(appId)) != null ? _a2 : minted, durable: false };
1130
1194
  };
1195
+ const adopted = (value) => ({ value, durable: true });
1131
1196
  let run;
1132
1197
  try {
1133
1198
  run = Promise.resolve(storage.getItem(slot)).then((saved) => {
1134
1199
  const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : void 0;
1135
1200
  if (persisted) {
1136
1201
  registry.keys.set(appId, persisted);
1137
- return persisted;
1202
+ return adopted(persisted);
1138
1203
  }
1139
- return Promise.resolve(storage.setItem(slot, minted)).then(settled, settled);
1140
- }).catch(settled);
1204
+ return Promise.resolve(storage.setItem(slot, minted)).then(
1205
+ () => {
1206
+ var _a2;
1207
+ return adopted((_a2 = registry.keys.get(appId)) != null ? _a2 : minted);
1208
+ },
1209
+ degraded
1210
+ );
1211
+ }).catch(degraded);
1141
1212
  } catch {
1142
- run = Promise.resolve(settled());
1213
+ run = Promise.resolve(degraded());
1143
1214
  }
1144
1215
  registry.pending.set(appId, run);
1216
+ void run.then((outcome) => {
1217
+ var _a2;
1218
+ if (outcome.durable) return;
1219
+ (_a2 = registry.pending) == null ? void 0 : _a2.delete(appId);
1220
+ registry.hydrating.delete(appId);
1221
+ });
1145
1222
  return run;
1146
1223
  };
1147
1224
  var resolveAutoDeviceKey = (opts = {}) => {
@@ -1169,11 +1246,6 @@ var resetAutoDeviceKeys = () => {
1169
1246
  };
1170
1247
 
1171
1248
  // src/analytics/analyticsFacade.ts
1172
- var warnInDev2 = (message) => {
1173
- if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
1174
- console.warn(message);
1175
- }
1176
- };
1177
1249
  var createAnalytics = (config, options = {}) => {
1178
1250
  var _a2, _b, _c;
1179
1251
  const resolveSessionId = () => {
@@ -1181,13 +1253,19 @@ var createAnalytics = (config, options = {}) => {
1181
1253
  return (_a3 = config.sessionId) != null ? _a3 : ensureCurrentSessionId();
1182
1254
  };
1183
1255
  if (config.sessionId) {
1184
- warnInDev2(
1256
+ warnInDev(
1185
1257
  "[wireai] createAnalytics({ sessionId }) PINS every event from this instance to that one frozen id and opts out of the live per-open session (app.session_started) \u2014 lifecycle analytics collapse onto a single device-scoped id. Remove it unless your host runs its own session lifecycle."
1186
1258
  );
1187
1259
  }
1188
1260
  const detectedAppVersion = detectAppVersion();
1189
1261
  let userContext = { ...(_a2 = config.userContext) != null ? _a2 : {} };
1190
1262
  const hostDeviceKeyAtInit = typeof ((_b = config.userContext) == null ? void 0 : _b.deviceKey) === "string" && config.userContext.deviceKey.trim() ? config.userContext.deviceKey.trim() : void 0;
1263
+ resolveIdentity({
1264
+ value: hostDeviceKeyAtInit,
1265
+ space: "device",
1266
+ source: "host",
1267
+ scope: config.appId
1268
+ });
1191
1269
  const autoDeviceKeyOptions = {
1192
1270
  appId: config.appId,
1193
1271
  // A host-supplied deviceKey opts out of minting AND persisting (unchanged contract).
@@ -1233,7 +1311,7 @@ var createAnalytics = (config, options = {}) => {
1233
1311
  };
1234
1312
  const guardUserId = (clean) => {
1235
1313
  if (config.allowEmailAsUserId || !looksLikeEmail(clean)) return clean;
1236
- warnInDev2(
1314
+ warnInDev(
1237
1315
  "[wireai] identify() was called with an email-shaped id. A raw email must NOT be the opaque user_id (PII leak) \u2014 pass it as userContext.userEmail instead. Binding was skipped. Set allowEmailAsUserId:true on createAnalytics if your user id genuinely is an email."
1238
1316
  );
1239
1317
  return void 0;
@@ -1354,6 +1432,7 @@ exports.reportClientEvents = reportClientEvents;
1354
1432
  exports.reportClientEventsAwait = reportClientEventsAwait;
1355
1433
  exports.resetAutoDeviceKeys = resetAutoDeviceKeys;
1356
1434
  exports.resetCurrentSessionId = resetCurrentSessionId;
1435
+ exports.resetEventQueueKeys = resetEventQueueKeys;
1357
1436
  exports.resolveAutoDeviceKey = resolveAutoDeviceKey;
1358
1437
  exports.screenTrackingHandler = screenTrackingHandler;
1359
1438
  exports.setCurrentSessionId = setCurrentSessionId;