pulse-updates 1.2.3 → 1.2.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/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,29 @@ 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
+ outcomeDeliveryInfo(): OutcomeDeliveryInfo;
71
+ dispose(): Promise<void>;
60
72
  }
61
73
 
74
+ export interface OutcomeDeliveryInfo {
75
+ queueDepth: number;
76
+ sending: boolean;
77
+ nextAttemptAt: number;
78
+ }
79
+
80
+ interface QueuedOutcome {
81
+ decisionId: string;
82
+ outcome: DecisionOutcome;
83
+ metadata?: Record<string, unknown>;
84
+ revision: string;
85
+ attempts: number;
86
+ }
87
+
88
+ const DEFAULT_OUTCOME_FLUSH_MS = 10_000;
89
+ const DEFAULT_OUTCOME_MAX_QUEUE = 100;
90
+
62
91
  /** Generic decision protocol. Product-specific action handlers remain in each app. */
63
92
  export function createPulseDecisionClient(options: PulseDecisionClientOptions): PulseDecisionClient {
64
93
  const fetcher = options.fetch ?? globalThis.fetch;
@@ -66,26 +95,83 @@ export function createPulseDecisionClient(options: PulseDecisionClientOptions):
66
95
  const slug = encodeURIComponent(options.appSlug);
67
96
  const timeoutMs = Math.max(250, options.timeoutMs ?? 3_000);
68
97
  const executionMode = options.executionMode ?? 'shadow-only';
98
+ const storageKey = options.storageKey?.trim() || `pulse.${options.appSlug.trim()}.outcomes.v1`;
99
+ const maxQueue = Math.max(1, options.maxQueue ?? DEFAULT_OUTCOME_MAX_QUEUE);
100
+ let outcomeQueue = readOutcomeQueue(options.storage, storageKey, maxQueue);
101
+ let outcomeSending = false;
102
+ let outcomeNextAttemptAt = 0;
103
+ let disposed = false;
69
104
 
70
- const reportOutcome = async (
71
- decisionId: string,
72
- outcome: DecisionOutcome,
73
- metadata?: Record<string, unknown>,
74
- ): Promise<boolean> => {
105
+ const persistOutcomes = (): void => {
106
+ if (!options.storage) return;
107
+ try { options.storage.set(storageKey, JSON.stringify(outcomeQueue)); }
108
+ catch {
109
+ // A storage failure may reduce durability, but decision execution itself must
110
+ // remain available and the caller still receives the delivery result.
111
+ }
112
+ };
113
+
114
+ const flushOutcomes = async (): Promise<number> => {
115
+ if (disposed || outcomeSending || outcomeQueue.length === 0 || Date.now() < outcomeNextAttemptAt) return 0;
116
+ outcomeSending = true;
117
+ const queued = outcomeQueue[0]!;
75
118
  try {
76
119
  const response = await timedFetch(fetcher,
77
- `${base}/pulse/decisions/${slug}/${encodeURIComponent(decisionId)}/outcome`, {
120
+ `${base}/pulse/decisions/${slug}/${encodeURIComponent(queued.decisionId)}/outcome`, {
78
121
  method: 'POST',
79
122
  headers: { 'Content-Type': 'application/json' },
80
- body: JSON.stringify({ outcome, ...(metadata ? { metadata } : {}) }),
123
+ body: JSON.stringify({
124
+ outcome: queued.outcome,
125
+ ...(queued.metadata ? { metadata: queued.metadata } : {}),
126
+ }),
81
127
  }, timeoutMs);
82
- return response.ok;
128
+ if (!response.ok) throw new Error(`Pulse outcome HTTP ${response.status}`);
129
+
130
+ // A newer terminal state may have replaced this one while the request was in
131
+ // flight (accepted -> converted). Remove only the exact revision delivered.
132
+ outcomeQueue = outcomeQueue.filter((item) =>
133
+ item.decisionId !== queued.decisionId || item.revision !== queued.revision);
134
+ outcomeNextAttemptAt = 0;
135
+ persistOutcomes();
136
+ return 1;
83
137
  } catch (error) {
138
+ const current = outcomeQueue.find((item) => item.decisionId === queued.decisionId);
139
+ if (current?.revision === queued.revision) current.attempts += 1;
140
+ const attempts = Math.min((current?.attempts ?? queued.attempts + 1), 6);
141
+ const baseDelay = Math.min(60_000, 1_000 * 2 ** Math.max(0, attempts - 1));
142
+ outcomeNextAttemptAt = Date.now() + baseDelay;
143
+ persistOutcomes();
84
144
  options.onError?.(error);
85
- return false;
145
+ return 0;
146
+ } finally {
147
+ outcomeSending = false;
148
+ if (!disposed && outcomeNextAttemptAt === 0 && outcomeQueue.length > 0) void flushOutcomes();
86
149
  }
87
150
  };
88
151
 
152
+ const reportOutcome = async (
153
+ decisionId: string,
154
+ outcome: DecisionOutcome,
155
+ metadata?: Record<string, unknown>,
156
+ ): Promise<boolean> => {
157
+ if (!decisionId.trim() || disposed) return false;
158
+ const next: QueuedOutcome = {
159
+ decisionId,
160
+ outcome,
161
+ metadata,
162
+ revision: randomKey(),
163
+ attempts: 0,
164
+ };
165
+ const existing = outcomeQueue.findIndex((item) => item.decisionId === decisionId);
166
+ if (existing >= 0) outcomeQueue[existing] = next;
167
+ else outcomeQueue.push(next);
168
+ outcomeQueue = outcomeQueue.slice(-maxQueue);
169
+ outcomeNextAttemptAt = 0;
170
+ persistOutcomes();
171
+ await flushOutcomes();
172
+ return !outcomeQueue.some((item) => item.decisionId === decisionId && item.revision === next.revision);
173
+ };
174
+
89
175
  const decide = async (trigger: string, request: DecideOptions = {}): Promise<PulseDecision | null> => {
90
176
  try {
91
177
  const context = await options.getContext();
@@ -133,7 +219,50 @@ export function createPulseDecisionClient(options: PulseDecisionClientOptions):
133
219
  }
134
220
  };
135
221
 
136
- return { decide, reportOutcome };
222
+ const timer = setInterval(() => void flushOutcomes(), options.flushIntervalMs ?? DEFAULT_OUTCOME_FLUSH_MS);
223
+ (timer as unknown as { unref?: () => void }).unref?.();
224
+ void flushOutcomes();
225
+
226
+ return {
227
+ decide,
228
+ reportOutcome,
229
+ flushOutcomes,
230
+ pendingOutcomeCount: () => outcomeQueue.length,
231
+ outcomeDeliveryInfo: () => ({
232
+ queueDepth: outcomeQueue.length,
233
+ sending: outcomeSending,
234
+ nextAttemptAt: outcomeNextAttemptAt,
235
+ }),
236
+ dispose: async () => {
237
+ clearInterval(timer);
238
+ await flushOutcomes();
239
+ disposed = true;
240
+ persistOutcomes();
241
+ },
242
+ };
243
+ }
244
+
245
+ function readOutcomeQueue(
246
+ storage: ConfigStorage | undefined,
247
+ key: string,
248
+ maxQueue: number,
249
+ ): QueuedOutcome[] {
250
+ if (!storage) return [];
251
+ try {
252
+ const raw = storage.getString(key);
253
+ if (!raw) return [];
254
+ const parsed = JSON.parse(raw) as unknown;
255
+ if (!Array.isArray(parsed)) return [];
256
+ return parsed.filter((item): item is QueuedOutcome => Boolean(
257
+ item && typeof item === 'object' &&
258
+ typeof (item as QueuedOutcome).decisionId === 'string' &&
259
+ typeof (item as QueuedOutcome).outcome === 'string' &&
260
+ typeof (item as QueuedOutcome).revision === 'string' &&
261
+ typeof (item as QueuedOutcome).attempts === 'number',
262
+ )).slice(-maxQueue);
263
+ } catch {
264
+ return [];
265
+ }
137
266
  }
138
267
 
139
268
  function parseDecision(raw: unknown): PulseDecision {
@@ -177,3 +306,4 @@ function randomKey(): string {
177
306
  if (typeof cryptoLike?.randomUUID === 'function') return cryptoLike.randomUUID();
178
307
  return `moment-${Date.now()}-${Math.random().toString(36).slice(2, 14)}`;
179
308
  }
309
+ 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));