pulse-updates 1.2.3 → 1.2.4

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/src/decisions.ts CHANGED
@@ -45,6 +45,14 @@ export interface PulseDecisionClientOptions {
45
45
  /** Safe default. User-facing execution needs an explicit per-app opt-in. */
46
46
  executionMode?: 'shadow-only' | 'allow-live';
47
47
  handlers?: Record<string, DecisionHandler>;
48
+ /** Persists terminal outcomes across process death and offline periods. */
49
+ storage?: ConfigStorage;
50
+ /** App-scoped outcome outbox key. Defaults to one derived from appSlug. */
51
+ storageKey?: string;
52
+ /** Retry tick for pending outcomes. Default 10s. */
53
+ flushIntervalMs?: number;
54
+ /** Bound on decisions awaiting delivery. Default 100. */
55
+ maxQueue?: number;
48
56
  onError?: (error: unknown) => void;
49
57
  }
50
58
 
@@ -57,8 +65,22 @@ export interface DecideOptions {
57
65
  export interface PulseDecisionClient {
58
66
  decide(trigger: string, options?: DecideOptions): Promise<PulseDecision | null>;
59
67
  reportOutcome(decisionId: string, outcome: DecisionOutcome, metadata?: Record<string, unknown>): Promise<boolean>;
68
+ flushOutcomes(): Promise<number>;
69
+ pendingOutcomeCount(): number;
70
+ dispose(): Promise<void>;
60
71
  }
61
72
 
73
+ interface QueuedOutcome {
74
+ decisionId: string;
75
+ outcome: DecisionOutcome;
76
+ metadata?: Record<string, unknown>;
77
+ revision: string;
78
+ attempts: number;
79
+ }
80
+
81
+ const DEFAULT_OUTCOME_FLUSH_MS = 10_000;
82
+ const DEFAULT_OUTCOME_MAX_QUEUE = 100;
83
+
62
84
  /** Generic decision protocol. Product-specific action handlers remain in each app. */
63
85
  export function createPulseDecisionClient(options: PulseDecisionClientOptions): PulseDecisionClient {
64
86
  const fetcher = options.fetch ?? globalThis.fetch;
@@ -66,26 +88,83 @@ export function createPulseDecisionClient(options: PulseDecisionClientOptions):
66
88
  const slug = encodeURIComponent(options.appSlug);
67
89
  const timeoutMs = Math.max(250, options.timeoutMs ?? 3_000);
68
90
  const executionMode = options.executionMode ?? 'shadow-only';
91
+ const storageKey = options.storageKey?.trim() || `pulse.${options.appSlug.trim()}.outcomes.v1`;
92
+ const maxQueue = Math.max(1, options.maxQueue ?? DEFAULT_OUTCOME_MAX_QUEUE);
93
+ let outcomeQueue = readOutcomeQueue(options.storage, storageKey, maxQueue);
94
+ let outcomeSending = false;
95
+ let outcomeNextAttemptAt = 0;
96
+ let disposed = false;
69
97
 
70
- const reportOutcome = async (
71
- decisionId: string,
72
- outcome: DecisionOutcome,
73
- metadata?: Record<string, unknown>,
74
- ): Promise<boolean> => {
98
+ const persistOutcomes = (): void => {
99
+ if (!options.storage) return;
100
+ try { options.storage.set(storageKey, JSON.stringify(outcomeQueue)); }
101
+ catch {
102
+ // A storage failure may reduce durability, but decision execution itself must
103
+ // remain available and the caller still receives the delivery result.
104
+ }
105
+ };
106
+
107
+ const flushOutcomes = async (): Promise<number> => {
108
+ if (disposed || outcomeSending || outcomeQueue.length === 0 || Date.now() < outcomeNextAttemptAt) return 0;
109
+ outcomeSending = true;
110
+ const queued = outcomeQueue[0]!;
75
111
  try {
76
112
  const response = await timedFetch(fetcher,
77
- `${base}/pulse/decisions/${slug}/${encodeURIComponent(decisionId)}/outcome`, {
113
+ `${base}/pulse/decisions/${slug}/${encodeURIComponent(queued.decisionId)}/outcome`, {
78
114
  method: 'POST',
79
115
  headers: { 'Content-Type': 'application/json' },
80
- body: JSON.stringify({ outcome, ...(metadata ? { metadata } : {}) }),
116
+ body: JSON.stringify({
117
+ outcome: queued.outcome,
118
+ ...(queued.metadata ? { metadata: queued.metadata } : {}),
119
+ }),
81
120
  }, timeoutMs);
82
- return response.ok;
121
+ if (!response.ok) throw new Error(`Pulse outcome HTTP ${response.status}`);
122
+
123
+ // A newer terminal state may have replaced this one while the request was in
124
+ // flight (accepted -> converted). Remove only the exact revision delivered.
125
+ outcomeQueue = outcomeQueue.filter((item) =>
126
+ item.decisionId !== queued.decisionId || item.revision !== queued.revision);
127
+ outcomeNextAttemptAt = 0;
128
+ persistOutcomes();
129
+ return 1;
83
130
  } catch (error) {
131
+ const current = outcomeQueue.find((item) => item.decisionId === queued.decisionId);
132
+ if (current?.revision === queued.revision) current.attempts += 1;
133
+ const attempts = Math.min((current?.attempts ?? queued.attempts + 1), 6);
134
+ const baseDelay = Math.min(60_000, 1_000 * 2 ** Math.max(0, attempts - 1));
135
+ outcomeNextAttemptAt = Date.now() + baseDelay;
136
+ persistOutcomes();
84
137
  options.onError?.(error);
85
- return false;
138
+ return 0;
139
+ } finally {
140
+ outcomeSending = false;
141
+ if (!disposed && outcomeNextAttemptAt === 0 && outcomeQueue.length > 0) void flushOutcomes();
86
142
  }
87
143
  };
88
144
 
145
+ const reportOutcome = async (
146
+ decisionId: string,
147
+ outcome: DecisionOutcome,
148
+ metadata?: Record<string, unknown>,
149
+ ): Promise<boolean> => {
150
+ if (!decisionId.trim() || disposed) return false;
151
+ const next: QueuedOutcome = {
152
+ decisionId,
153
+ outcome,
154
+ metadata,
155
+ revision: randomKey(),
156
+ attempts: 0,
157
+ };
158
+ const existing = outcomeQueue.findIndex((item) => item.decisionId === decisionId);
159
+ if (existing >= 0) outcomeQueue[existing] = next;
160
+ else outcomeQueue.push(next);
161
+ outcomeQueue = outcomeQueue.slice(-maxQueue);
162
+ outcomeNextAttemptAt = 0;
163
+ persistOutcomes();
164
+ await flushOutcomes();
165
+ return !outcomeQueue.some((item) => item.decisionId === decisionId && item.revision === next.revision);
166
+ };
167
+
89
168
  const decide = async (trigger: string, request: DecideOptions = {}): Promise<PulseDecision | null> => {
90
169
  try {
91
170
  const context = await options.getContext();
@@ -133,7 +212,45 @@ export function createPulseDecisionClient(options: PulseDecisionClientOptions):
133
212
  }
134
213
  };
135
214
 
136
- return { decide, reportOutcome };
215
+ const timer = setInterval(() => void flushOutcomes(), options.flushIntervalMs ?? DEFAULT_OUTCOME_FLUSH_MS);
216
+ (timer as unknown as { unref?: () => void }).unref?.();
217
+ void flushOutcomes();
218
+
219
+ return {
220
+ decide,
221
+ reportOutcome,
222
+ flushOutcomes,
223
+ pendingOutcomeCount: () => outcomeQueue.length,
224
+ dispose: async () => {
225
+ clearInterval(timer);
226
+ await flushOutcomes();
227
+ disposed = true;
228
+ persistOutcomes();
229
+ },
230
+ };
231
+ }
232
+
233
+ function readOutcomeQueue(
234
+ storage: ConfigStorage | undefined,
235
+ key: string,
236
+ maxQueue: number,
237
+ ): QueuedOutcome[] {
238
+ if (!storage) return [];
239
+ try {
240
+ const raw = storage.getString(key);
241
+ if (!raw) return [];
242
+ const parsed = JSON.parse(raw) as unknown;
243
+ if (!Array.isArray(parsed)) return [];
244
+ return parsed.filter((item): item is QueuedOutcome => Boolean(
245
+ item && typeof item === 'object' &&
246
+ typeof (item as QueuedOutcome).decisionId === 'string' &&
247
+ typeof (item as QueuedOutcome).outcome === 'string' &&
248
+ typeof (item as QueuedOutcome).revision === 'string' &&
249
+ typeof (item as QueuedOutcome).attempts === 'number',
250
+ )).slice(-maxQueue);
251
+ } catch {
252
+ return [];
253
+ }
137
254
  }
138
255
 
139
256
  function parseDecision(raw: unknown): PulseDecision {
@@ -177,3 +294,4 @@ function randomKey(): string {
177
294
  if (typeof cryptoLike?.randomUUID === 'function') return cryptoLike.randomUUID();
178
295
  return `moment-${Date.now()}-${Math.random().toString(36).slice(2, 14)}`;
179
296
  }
297
+ import type { ConfigStorage } from './config';
package/src/init.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  getConfigNumber,
37
37
  getConfigString,
38
38
  getConfigJson,
39
+ getExposureDeliveryInfo,
39
40
  } from './config';
40
41
  import {
41
42
  configureTracking,
@@ -100,6 +101,9 @@ export interface InitPulseOptions {
100
101
  /** Foreground poll interval for the config. 0 disables it; launch and resume still fetch. */
101
102
  pollIntervalMs?: number;
102
103
 
104
+ /** Report experiment assignments through the durable outbox. Defaults to true. */
105
+ reportExposure?: boolean;
106
+
103
107
  /** How often queued events are sent. Default 10s. */
104
108
  flushIntervalMs?: number;
105
109
 
@@ -119,6 +123,7 @@ export interface PulseHealth {
119
123
  deviceId: string;
120
124
  config: ReturnType<typeof getConfigInfo>;
121
125
  events: ReturnType<typeof getTrackingInfo>;
126
+ exposures: ReturnType<typeof getExposureDeliveryInfo>;
122
127
  analyticsConsent: boolean;
123
128
  }
124
129
 
@@ -196,6 +201,8 @@ export function initPulse(opts: InitPulseOptions): PulseClient {
196
201
  defaults: opts.defaults,
197
202
  storage: opts.storage,
198
203
  storageKey: `pulse.${slug}.config.v1`,
204
+ exposureStorageKey: `pulse.${slug}.exposures.v1`,
205
+ reportExposure: opts.reportExposure ?? true,
199
206
  getContext,
200
207
  pollIntervalMs: opts.pollIntervalMs,
201
208
  onError: opts.onError,
@@ -260,7 +267,14 @@ export function initPulse(opts: InitPulseOptions): PulseClient {
260
267
  try { opts.storage?.set(deviceStorageKey, deviceId); } catch { /* memory-only fallback */ }
261
268
  return deviceId;
262
269
  },
263
- health: () => ({ appSlug: slug, deviceId, config: getConfigInfo(), events: getTrackingInfo(), analyticsConsent: hasConsent() }),
270
+ health: () => ({
271
+ appSlug: slug,
272
+ deviceId,
273
+ config: getConfigInfo(),
274
+ events: getTrackingInfo(),
275
+ exposures: getExposureDeliveryInfo(),
276
+ analyticsConsent: hasConsent(),
277
+ }),
264
278
  config: {
265
279
  boolean: getConfigBoolean,
266
280
  number: getConfigNumber,
@@ -297,6 +311,7 @@ export async function initPulseAsync(
297
311
  `pulse.${slug}.device-id`,
298
312
  `pulse.${slug}.config.v1`,
299
313
  `pulse.${slug}.events.v1`,
314
+ `pulse.${slug}.exposures.v1`,
300
315
  DEVICE_ID_KEY,
301
316
  ];
302
317
  const loaded = await Promise.all(keys.map(async (key) => [key, await opts.storage.getItem(key)] as const));