@wireai/activation 0.12.2 → 0.13.2

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 (55) hide show
  1. package/AGENTS.md +3 -1
  2. package/CHANGELOG.md +259 -1
  3. package/README.md +87 -3
  4. package/dist/analytics/index.d.mts +2 -2
  5. package/dist/analytics/index.d.ts +2 -2
  6. package/dist/analytics/index.js +174 -36
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +174 -37
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/{currentSession-BlCeDP0f.d.mts → currentSession-ClkLjcJ0.d.mts} +456 -19
  11. package/dist/{currentSession-BxEB37xt.d.ts → currentSession-DOVZEWJl.d.ts} +456 -19
  12. package/dist/index.d.mts +197 -16
  13. package/dist/index.d.ts +197 -16
  14. package/dist/index.js +1056 -390
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +836 -193
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/questionnaire/index.js.map +1 -1
  19. package/dist/questionnaire/index.mjs.map +1 -1
  20. package/dist/reviews/index.js +20 -7
  21. package/dist/reviews/index.js.map +1 -1
  22. package/dist/reviews/index.mjs +20 -7
  23. package/dist/reviews/index.mjs.map +1 -1
  24. package/package.json +1 -1
  25. package/src/OnboardingFlow.tsx +141 -3
  26. package/src/WireOnboarding.tsx +178 -34
  27. package/src/activation/wireActivation.ts +13 -7
  28. package/src/analytics/analyticsEvent.ts +16 -1
  29. package/src/analytics/analyticsFacade.ts +11 -10
  30. package/src/analytics/currentSession.ts +6 -20
  31. package/src/analytics/eventQueue.ts +71 -1
  32. package/src/analytics/index.ts +1 -1
  33. package/src/analytics/reportClientEvent.ts +157 -38
  34. package/src/cards/PermissionCard.tsx +438 -0
  35. package/src/cards/index.ts +7 -0
  36. package/src/config/wireConfigFromEnv.ts +1 -10
  37. package/src/context/deviceId.ts +77 -16
  38. package/src/context/userContext.ts +4 -15
  39. package/src/identity/identityRecord.ts +123 -0
  40. package/src/identity/userIdentity.ts +45 -9
  41. package/src/illustrations/defaultIllustrations.tsx +44 -3
  42. package/src/index.ts +44 -4
  43. package/src/permissions/index.ts +64 -0
  44. package/src/permissions/permissionCopy.ts +87 -0
  45. package/src/permissions/permissionEvents.ts +76 -0
  46. package/src/permissions/permissionMemory.ts +88 -0
  47. package/src/permissions/placement.ts +88 -0
  48. package/src/permissions/types.ts +131 -0
  49. package/src/session/persistedSession.ts +10 -3
  50. package/src/session-analytics/useLifecycleEvents.ts +10 -1
  51. package/src/types.ts +77 -1
  52. package/src/utils/deriveAnswers.ts +6 -2
  53. package/src/utils/readProgress.ts +4 -0
  54. package/src/utils/warnInDev.ts +33 -0
  55. package/src/components/DoneBlock.tsx +0 -37
@@ -38,17 +38,9 @@ import {
38
38
  } from "../context/userContext";
39
39
  import { resolveAutoDeviceKey, type ResolveAutoDeviceKeyOptions } from "../context/deviceId";
40
40
  import { detectAppVersion } from "../device/appVersion";
41
+ import { resolveIdentity } from "../identity/identityRecord";
41
42
  import { looksLikeEmail, sanitizeUserId } from "../identity/userIdentity";
42
-
43
- /** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
44
- declare const __DEV__: boolean | undefined;
45
-
46
- /** Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests. */
47
- const warnInDev = (message: string): void => {
48
- if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
49
- console.warn(message);
50
- }
51
- };
43
+ import { warnInDev } from "../utils/warnInDev";
52
44
 
53
45
  /** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
54
46
  export type AnalyticsProps = Record<string, unknown>;
@@ -204,6 +196,15 @@ export const createAnalytics = (
204
196
  typeof config.userContext?.deviceKey === "string" && config.userContext.deviceKey.trim()
205
197
  ? config.userContext.deviceKey.trim()
206
198
  : undefined;
199
+ // Record a HOST-supplied key on the process provenance registry, so a `<WireOnboarding>` mount that
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 — K9). Recording only; nothing reads it here.
202
+ resolveIdentity({
203
+ value: hostDeviceKeyAtInit,
204
+ space: "device",
205
+ source: "host",
206
+ scope: config.appId,
207
+ });
207
208
  // The auto id comes from the ONE process-wide registry (`resolveAutoDeviceKey`), NOT a mint local to
208
209
  // this instance. A host that also builds a `createWireActivation` instance used to get a SECOND,
209
210
  // different auto id for the same install, splitting `device_key` across two id spaces — see the
@@ -48,6 +48,12 @@
48
48
  * app-open: it warns once in dev, naming the fix.
49
49
  */
50
50
  import { makeSessionId } from "./reportClientEvent";
51
+ // The shared primitive returns whether it ACTUALLY warned, which the once-flag below depends on:
52
+ // marking "already warned" after a prod no-op would burn the single warning and leave the one dev
53
+ // build that needed it silent. (The old local copy justified itself as keeping this module
54
+ // import-free for the tree-shaken analytics bundle; that had already lapsed — it imports
55
+ // `makeSessionId` right here — and `utils/warnInDev` has no imports of its own.)
56
+ import { warnInDev } from "../utils/warnInDev";
51
57
 
52
58
  /**
53
59
  * Well-known key into the runtime-global symbol registry. `Symbol.for` (NOT a plain `Symbol()`) is
@@ -83,26 +89,6 @@ export const resetCurrentSessionId = (): void => {
83
89
  globalSlot[CURRENT_SESSION_ID_SLOT] = undefined;
84
90
  };
85
91
 
86
- /** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
87
- declare const __DEV__: boolean | undefined;
88
-
89
- /**
90
- * Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests.
91
- * Same idiom as `analyticsFacade.warnInDev` — deliberately duplicated rather than imported, so this
92
- * module keeps its zero-import-weight for the tree-shaken analytics bundle.
93
- *
94
- * Returns whether it ACTUALLY warned, so the caller's once-flag is spent on a warning a developer
95
- * saw. Marking "already warned" after a no-op would burn the single warning in prod, and the one
96
- * dev build that needed it would then run silent.
97
- */
98
- const warnInDev = (message: string): boolean => {
99
- if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
100
- console.warn(message);
101
- return true;
102
- }
103
- return false;
104
- };
105
-
106
92
  /** The one-time message. Hoisted so a prod mint does not rebuild a string nobody will read. */
107
93
  const MINT_WARNING =
108
94
  "[wireai] No app-open session was registered, so a session id was minted for this event " +
@@ -31,6 +31,7 @@ import {
31
31
  type ClientEventTarget,
32
32
  } from "./reportClientEvent";
33
33
  import type { WireOnboardingStorage } from "../session/persistedSession";
34
+ import { warnInDev } from "../utils/warnInDev";
34
35
 
35
36
  /** Envelope source: a fixed envelope or a provider evaluated at enqueue time (fresh network type). */
36
37
  export type EnvelopeSource = ContextEnvelope | (() => ContextEnvelope | undefined);
@@ -85,6 +86,66 @@ const DEFAULTS = {
85
86
  /** Ceiling on the persisted-backlog read — a hung adapter degrades to an empty start, never a stall. */
86
87
  const READ_TIMEOUT_MS = 1500;
87
88
 
89
+ // ── One storage slot per QUEUE, not per appId (K4) ───────────────────────────────────────────────
90
+ //
91
+ // The default key was derived from `appId` alone, so two `createAnalytics` instances for one tenant —
92
+ // the documented double-wiring, a façade for `track`/`screen` plus an activation instance — shared ONE
93
+ // persisted backlog while keeping SEPARATE in-memory buffers. Every symptom of that is silent:
94
+ // each `persist()` overwrites the other's blob with its own view of "pending"; a queue that drains to
95
+ // empty calls `removeItem` and DELETES a sibling's still-pending events; and on relaunch whatever
96
+ // survived is loaded by both instances and sent twice. `useLifecycleEvents` already avoided all of it
97
+ // by handing its queue a dedicated explicit key — this generalizes that.
98
+ //
99
+ // A `Symbol.for` slot for the same reason as every other registry here: tsup inlines a copy of this
100
+ // module into each bundle, and a plain module `let` would let the `.` and `./analytics` copies each
101
+ // think they were the first claimant of the same key.
102
+ const QUEUE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:eventQueueKeys");
103
+
104
+ type GlobalWithQueueKeys = typeof globalThis & { [QUEUE_KEY_SLOT]?: Set<string> };
105
+
106
+ const queueKeyGlobal = globalThis as GlobalWithQueueKeys;
107
+
108
+ const claimedQueueKeys = (): Set<string> => {
109
+ const existing = queueKeyGlobal[QUEUE_KEY_SLOT];
110
+ if (existing) return existing;
111
+ const created = new Set<string>();
112
+ queueKeyGlobal[QUEUE_KEY_SLOT] = created;
113
+ return created;
114
+ };
115
+
116
+ /**
117
+ * Claim `preferred` for this queue, or the next free `preferred#N` when a live queue already holds it.
118
+ *
119
+ * ONLY the appId-derived DEFAULT is ever rotated. An EXPLICIT `storageKey` is the host (or
120
+ * `useLifecycleEvents`) declaring which slot a queue owns, and that hook re-creates its queue on every
121
+ * remount — rotating there would silently walk it off its own backlog once per remount, which is a
122
+ * worse bug than the one being fixed.
123
+ */
124
+ const claimQueueKey = (preferred: string, explicit: boolean): string => {
125
+ const claimed = claimedQueueKeys();
126
+ if (explicit || !claimed.has(preferred)) {
127
+ claimed.add(preferred);
128
+ return preferred;
129
+ }
130
+ let ordinal = 2;
131
+ while (claimed.has(`${preferred}#${ordinal}`)) ordinal++;
132
+ const key = `${preferred}#${ordinal}`;
133
+ claimed.add(key);
134
+ warnInDev(
135
+ `[wireai] a second event queue was created for the same appId, and "${preferred}" is already ` +
136
+ "claimed by a live one. Two queues sharing one storage slot overwrite each other's backlog, " +
137
+ "delete each other's pending events when one drains to empty, and double-send on relaunch, so " +
138
+ `this queue was given "${key}" instead. Prefer ONE analytics instance per app; if you really ` +
139
+ "need two, pass an explicit `storageKey` to each so the slots are yours to reason about.",
140
+ );
141
+ return key;
142
+ };
143
+
144
+ /** Test-only: forget every claimed queue key. A real RELAUNCH is a new process, so a test that
145
+ * simulates one in-process must call this or its second queue reads as a concurrent sibling.
146
+ * Exported from `@wireai/activation/analytics`, matching `resetAutoDeviceKeys` / `resetCurrentSessionId`. */
147
+ export const resetEventQueueKeys = (): void => claimedQueueKeys().clear();
148
+
88
149
  /** Internal buffered item. `id` is a local monotonic handle for deterministic dequeue-after-ack;
89
150
  * it is NEVER sent to the server. `sig` is the de-dup signature (serialized stamped event). */
90
151
  type QueuedItem = { id: number; event: ClientEvent; sig: string };
@@ -137,7 +198,14 @@ const parsePersisted = (raw: string | null | undefined): PersistedItem[] => {
137
198
  export const createEventQueue = (options: EventQueueOptions): EventQueue => {
138
199
  const target = options.target;
139
200
  const storage = options.storage;
140
- const key = options.storageKey ?? `wireai:evtq:${options.appId ?? "default"}`;
201
+ // One slot per QUEUE (K4): an explicit key is taken verbatim; the appId-derived default is rotated
202
+ // to `…#2` when a live queue already holds it, so two instances can never share one backlog.
203
+ //
204
+ // Only claimed when there IS storage. The defect is entirely about the persisted slot, and a
205
+ // storage-less queue (the documented degraded in-memory mode) never reads or writes the key — so
206
+ // claiming there would warn about a collision that cannot happen.
207
+ const defaultKey = options.storageKey ?? `wireai:evtq:${options.appId ?? "default"}`;
208
+ const key = storage ? claimQueueKey(defaultKey, options.storageKey !== undefined) : defaultKey;
141
209
  const maxSize = options.maxSize ?? DEFAULTS.maxSize;
142
210
  const batchSize = options.batchSize ?? DEFAULTS.batchSize;
143
211
  const baseBackoffMs = options.baseBackoffMs ?? DEFAULTS.baseBackoffMs;
@@ -170,6 +238,8 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
170
238
  // from a REDUNDANT re-enqueue of the same instant. The de-dup signature below includes it, so
171
239
  // two identical events enqueued in the same millisecond still collapse (a re-render), while the
172
240
  // same action repeated later carries a fresh `ts` and survives. A caller-set `ts` is preserved.
241
+ // This stays a NUMBER in the queue (the de-dup signature depends on it); `buildEventsRequest`
242
+ // serializes it to the ISO8601 string the server requires at send time.
173
243
  if (stamped.ts === undefined) stamped.ts = Date.now();
174
244
  if (!env) return stamped;
175
245
  if (!stamped.device && env.device) stamped.device = env.device;
@@ -49,7 +49,7 @@ export { buildContextEnvelope } from "./contextEnvelope";
49
49
  export type { ContextEnvelope, ContextEnvelopeInput } from "./contextEnvelope";
50
50
 
51
51
  // ─── Offline-first, persistent, batched + retried event queue (dependency-free) ─
52
- export { createEventQueue } from "./eventQueue";
52
+ export { createEventQueue, resetEventQueueKeys } from "./eventQueue";
53
53
  export type { EventQueue, EventQueueOptions, EnvelopeSource } from "./eventQueue";
54
54
 
55
55
  // ─── Developer-facing analytics façade (track / screen / identify) over the queue ─
@@ -77,15 +77,24 @@ export type ClientEvent = {
77
77
  */
78
78
  user_id?: string;
79
79
  /**
80
- * Client-stamped epoch-ms timestamp of when the event was ENQUEUED on the device. Optional and
81
- * ADDITIVE: the offline queue stamps it at enqueue time (see `createEventQueue`) so two otherwise
82
- * byte-identical events fired seconds apart (a genuine repeat, e.g. the user taps "share" twice)
83
- * are NOT collapsed by the queue's identical-JSON de-dup while two truly simultaneous
84
- * re-enqueues of the same instant (a redundant re-render) still share a `ts` and collapse. A raw
85
- * `Date.now()`, never a wall-clock the server trusts (the server derives its own receive time);
86
- * an old/strict server that does not model it simply ignores the unknown field.
80
+ * Client-stamped timestamp of when the event was ENQUEUED on the device. Optional and ADDITIVE.
81
+ *
82
+ * TWO REPRESENTATIONS, on purpose:
83
+ * - INTERNAL (epoch-ms `number`): what the offline queue stamps at enqueue time (see
84
+ * `createEventQueue`), so two otherwise byte-identical events fired seconds apart (a genuine
85
+ * repeat, e.g. the user taps "share" twice) are NOT collapsed by the queue's identical-JSON
86
+ * de-dup while two truly simultaneous re-enqueues of the same instant (a redundant
87
+ * re-render) still share a `ts` and collapse. The de-dup signature depends on this number.
88
+ * - WIRE (ISO8601 UTC `string`): what actually leaves the device. {@link buildEventsRequest}
89
+ * converts the number on its way out, because the server declares `ts: str | None` and
90
+ * pydantic v2 does NOT coerce a number into it — a numeric `ts` made the server answer HTTP
91
+ * 200 with `{written: 0, skipped: N, errors: [{field: "ts", reason: "validation_error"}]}`,
92
+ * silently discarding EVERY `app_event` through 0.13.0.
93
+ *
94
+ * A caller-set ISO string is passed through as-is. Never a wall-clock the server trusts (it
95
+ * derives its own receive time); an old/strict server that does not model it ignores the field.
87
96
  */
88
- ts?: number;
97
+ ts?: number | string;
89
98
  };
90
99
 
91
100
  /** Where to POST. Derived from `WireOnboardingConfig` (`serverUrl` + `apiKey`). */
@@ -105,12 +114,46 @@ export type ClientEventTarget = {
105
114
  export const makeSessionId = (): string =>
106
115
  `wire_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
107
116
 
117
+ /**
118
+ * Serialize the internal epoch-ms `ts` to the ISO8601 UTC string the WIRE requires.
119
+ *
120
+ * The server's event model declares `ts: str | None` and pydantic v2 does NOT coerce int → str, so
121
+ * a numeric `ts` fails per-event validation: the endpoint still answers HTTP 200, but with
122
+ * `{written: 0, skipped: N, errors: [{index, reason: "validation_error", field: "ts"}]}` — every
123
+ * `app_event` from `track()`/`screen()` silently discarded behind a green response.
124
+ *
125
+ * WHY HERE and not at the queue's `stamp()`: this is the single choke point all FOUR send paths go
126
+ * through (offline queue, fire-and-forget, awaitable, session-start). Converting here leaves the
127
+ * queue's numeric `ts` — and therefore its identical-JSON de-dup signature — exactly as it was, and
128
+ * it also converts the persisted 0.13.0 backlogs (which hold a numeric `ts`) on their way out.
129
+ *
130
+ * NEVER mutates the caller's event: an event that needs a change is copied. A string `ts` (a
131
+ * caller-set ISO stamp) and an absent `ts` pass through untouched. A non-finite (`NaN`/`Infinity`)
132
+ * or out-of-range number — the latter makes `toISOString` throw — drops the `ts` field from the
133
+ * copy rather than killing the whole batch.
134
+ */
135
+ const toWireEvents = (events: ClientEvent[]): ClientEvent[] =>
136
+ events.map((event) => {
137
+ if (typeof event.ts !== "number") return event;
138
+ const { ts, ...rest } = event;
139
+ if (!Number.isFinite(ts)) return rest;
140
+ try {
141
+ return { ...rest, ts: new Date(ts).toISOString() };
142
+ } catch {
143
+ // Out-of-range epoch-ms — send the event WITHOUT a ts rather than lose the batch.
144
+ return rest;
145
+ }
146
+ });
147
+
108
148
  /**
109
149
  * The ONE place the `/v1/events` POST is described (url + method + headers + body). Both the
110
150
  * fire-and-forget {@link reportClientEvents} and the awaitable {@link reportClientEventsAwait}
111
151
  * build their request here so there is a SINGLE definition of the events transport — no second
112
152
  * copy of the endpoint path, headers, or envelope shape to drift. Returns `null` when there is
113
153
  * nothing to send (no target / no events) or serialization throws, so callers just bail.
154
+ *
155
+ * It is also where the internal epoch-ms `ts` becomes the wire's ISO8601 string — see
156
+ * {@link toWireEvents} for why the conversion belongs at this choke point.
114
157
  */
115
158
  export const buildEventsRequest = (
116
159
  target: { serverUrl: string; apiKey?: string } | undefined,
@@ -121,23 +164,91 @@ export const buildEventsRequest = (
121
164
  const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
122
165
  const headers: Record<string, string> = { "Content-Type": "application/json" };
123
166
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
124
- return { url, init: { method: "POST", headers, body: JSON.stringify({ events }) } };
167
+ const body = JSON.stringify({ events: toWireEvents(events) });
168
+ return { url, init: { method: "POST", headers, body } };
125
169
  } catch {
126
170
  // URL construction or JSON serialization failed — nothing to send.
127
171
  return null;
128
172
  }
129
173
  };
130
174
 
175
+ /** What the `/v1/events` endpoint says in its 200 body. The deployed server DOES send `errors[]`,
176
+ * each entry carrying `{index, reason, field}` — `field` names the property that failed validation
177
+ * (it is what identified the `ts` rejection), so it is folded into the reason string here. Still
178
+ * read defensively: an older server sends no `errors` at all. */
179
+ type EventsAck = { written?: number; skipped: number; reasons: string[] };
180
+
181
+ /**
182
+ * Read the `/v1/events` ACK body. Resolves `undefined` when there is nothing readable — no `.json`
183
+ * (an old server, a bare test mock), an already-consumed body, or a hostile response object. NEVER
184
+ * throws, and NEVER dev-gated: the `skipped` count is a RETURN VALUE for the awaitable path, not only
185
+ * a warning, so it has to be read in production too.
186
+ *
187
+ * ⚠️ The body can be read exactly once, so a caller that both decides on `skipped` AND warns must do
188
+ * both from ONE call to this.
189
+ */
190
+ const readEventsAck = async (res: unknown): Promise<EventsAck | undefined> => {
191
+ try {
192
+ const json = (res as { json?: () => Promise<unknown> } | null | undefined)?.json;
193
+ if (typeof json !== "function") return undefined;
194
+ const body = (await Promise.resolve(json.call(res))) as
195
+ | { written?: unknown; skipped?: unknown; errors?: unknown }
196
+ | null
197
+ | undefined;
198
+ const skipped = body?.skipped;
199
+ if (typeof skipped !== "number" || !Number.isFinite(skipped)) return undefined;
200
+ const reasons = Array.isArray(body?.errors)
201
+ ? body.errors
202
+ .map((e) => {
203
+ const entry = e as { reason?: unknown; field?: unknown } | null;
204
+ const reason = entry?.reason;
205
+ if (typeof reason !== "string") return undefined;
206
+ // `field` is the whole point of a validation error — a bare "validation_error" sends the
207
+ // reader hunting; "validation_error (field: ts)" names the property the server refused.
208
+ const field = entry?.field;
209
+ return typeof field === "string" && field.length > 0
210
+ ? `${reason} (field: ${field})`
211
+ : reason;
212
+ })
213
+ .filter((r): r is string => typeof r === "string")
214
+ : [];
215
+ return { written: typeof body?.written === "number" ? body.written : undefined, skipped, reasons };
216
+ } catch {
217
+ // Unreadable / already-consumed body / hostile object — best-effort, treat as "no ack".
218
+ return undefined;
219
+ }
220
+ };
221
+
222
+ /**
223
+ * The DISCARDED warning text, built from what the server ACTUALLY said.
224
+ *
225
+ * It used to staple a cause onto a bare integer — "the usual cause is a missing or empty session_id".
226
+ * Since `ensureCurrentSessionId` shipped, no kit path can emit an event without a `session_id`, so
227
+ * that is now the LEAST likely explanation, and naming it sent every reader looking in the one place
228
+ * the problem is not. The endpoint folds four real failures and two idempotent no-ops into one
229
+ * integer, so unless the server volunteers reasons, the honest thing to report is the count and where
230
+ * the reason lives.
231
+ */
232
+ const describeDiscarded = (ack: EventsAck): string =>
233
+ `[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${ack.skipped} event(s) ` +
234
+ "(skipped in the response body) — they are gone, not retried. The server reported: " +
235
+ `skipped=${ack.skipped}${ack.written !== undefined ? `, written=${ack.written}` : ""}` +
236
+ (ack.reasons.length > 0
237
+ ? `, reasons: ${ack.reasons.join(", ")}.`
238
+ : ". It gave no reason (the endpoint folds every rejection into one count), so check the " +
239
+ "server's ingest log for this request rather than guessing.");
240
+
131
241
  /** RN sets this global; absent under node/SSR. Read defensively inside {@link warnOnSkippedEvents}. */
132
242
  declare const __DEV__: boolean | undefined;
133
243
 
134
244
  /**
135
245
  * Read the `/v1/events` ACK body and warn (dev builds only) when the server DISCARDED events.
136
246
  *
137
- * The endpoint answers HTTP **200** with `{ ok, written, skipped }` — an event it refuses (a missing
138
- * `session_id`, a malformed payload) is counted in `skipped`, never surfaced in the status code. Every
139
- * send path here reads `res.ok` alone, so a whole batch can evaporate behind a green response. This
140
- * consumes the body of the PERSISTENT paths (the offline queue + session-start) and names the count.
247
+ * The endpoint answers HTTP **200** with `{ ok, written, skipped }` — an event it refuses is counted
248
+ * in `skipped`, never surfaced in the status code. Every send path here reads `res.ok` alone, so a
249
+ * whole batch can evaporate behind a green response. As of 0.13.0 ALL FOUR send paths consume the ack:
250
+ * the offline queue, session-start, the fire-and-forget POST, and the awaitable one (which also acts
251
+ * on it — see {@link reportClientEventsAwait}).
141
252
  *
142
253
  * LOG ONLY: returns immediately, never throws, and never influences retry / dequeue / return values.
143
254
  * A response with no usable `.json` (an old server, a test mock) is silently ignored.
@@ -149,25 +260,10 @@ declare const __DEV__: boolean | undefined;
149
260
  export const warnOnSkippedEvents = (res: unknown): void => {
150
261
  if (typeof __DEV__ === "undefined" || !__DEV__) return;
151
262
  if (typeof console === "undefined" || !console.warn) return;
152
- try {
153
- const json = (res as { json?: () => Promise<unknown> } | null | undefined)?.json;
154
- if (typeof json !== "function") return;
155
- void Promise.resolve(json.call(res))
156
- .then((body) => {
157
- const skipped = (body as { skipped?: unknown } | null | undefined)?.skipped;
158
- if (typeof skipped !== "number" || skipped <= 0) return;
159
- console.warn(
160
- `[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${skipped} event(s) ` +
161
- "(skipped in the response body) — they are gone, not retried. The usual cause is an " +
162
- "event with a missing or empty session_id.",
163
- );
164
- })
165
- .catch(() => {
166
- // Unreadable / already-consumed body — best-effort logging, swallow.
167
- });
168
- } catch {
169
- // A hostile response object — swallow.
170
- }
263
+ void readEventsAck(res).then((ack) => {
264
+ if (!ack || ack.skipped <= 0) return;
265
+ console.warn(describeDiscarded(ack));
266
+ });
171
267
  };
172
268
 
173
269
  /**
@@ -182,9 +278,16 @@ export const reportClientEvents = (
182
278
  try {
183
279
  const req = buildEventsRequest(target, events);
184
280
  if (!req) return;
185
- void fetch(req.url, req.init).catch(() => {
186
- // Network/transport error — analytics is best-effort, swallow.
187
- });
281
+ void fetch(req.url, req.init)
282
+ .then((res) => {
283
+ // Still fire-and-forget — nothing is retried, nothing is returned. But a batch that
284
+ // evaporated behind a 200 is exactly what this path used to make invisible, so in dev it
285
+ // now says so, like the queue and session-start paths already did.
286
+ warnOnSkippedEvents(res);
287
+ })
288
+ .catch(() => {
289
+ // Network/transport error — analytics is best-effort, swallow.
290
+ });
188
291
  } catch {
189
292
  // A missing `fetch` — swallow.
190
293
  }
@@ -201,8 +304,17 @@ export const reportClientEvent = (
201
304
  * `/v1/events` path, but resolve only once the server has RESPONDED — so a decision re-fetch fired
202
305
  * immediately after is guaranteed to see the event in the session stream (this is the guarantee
203
306
  * `wire.track` needs before it triggers decision revalidation). Never throws: a missing/invalid
204
- * target, a missing `fetch`, a network error, or a non-2xx status all resolve to `false`. Resolves
205
- * `true` only on a 2xx response.
307
+ * target, a missing `fetch`, a network error, or a non-2xx status all resolve to `false`.
308
+ *
309
+ * ⚠️ 0.13.0: a 2xx is NO LONGER SUFFICIENT. The endpoint answers HTTP 200 with `{ ok, written,
310
+ * skipped }` and counts an event it refuses in `skipped`, so this used to resolve `true` for an event
311
+ * the server had thrown away — and `wire.track` then bumped decision revalidation, making every
312
+ * subscribed gate re-fetch against a stream the action never entered. It now reads the ack and
313
+ * resolves `false` when the server reports a positive `skipped`.
314
+ *
315
+ * BACKWARD COMPATIBLE BY CONSTRUCTION: only an explicit positive `skipped` demotes a 200. An old
316
+ * server that sends no such field, a body that cannot be parsed, or a response with no `.json` at all
317
+ * resolves `true` exactly as before — the change can produce no false negatives.
206
318
  */
207
319
  export const reportClientEventsAwait = async (
208
320
  target: ClientEventTarget | undefined,
@@ -212,7 +324,14 @@ export const reportClientEventsAwait = async (
212
324
  const req = buildEventsRequest(target, events);
213
325
  if (!req) return false;
214
326
  const res = await fetch(req.url, req.init);
215
- return Boolean(res && res.ok);
327
+ if (!res || !res.ok) return false;
328
+ // ONE body read, used for both the verdict and the dev warning — it can only be read once.
329
+ const ack = await readEventsAck(res);
330
+ if (!ack || ack.skipped <= 0) return true;
331
+ if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
332
+ console.warn(describeDiscarded(ack));
333
+ }
334
+ return false;
216
335
  } catch {
217
336
  // Unreachable / missing-fetch / network — best-effort, report failure.
218
337
  return false;