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/lib/commonjs/config.js +177 -12
- package/lib/commonjs/config.js.map +1 -1
- package/lib/commonjs/decisions.js +85 -8
- package/lib/commonjs/decisions.js.map +1 -1
- package/lib/commonjs/init.js +4 -1
- package/lib/commonjs/init.js.map +1 -1
- package/lib/module/config.js +174 -12
- package/lib/module/config.js.map +1 -1
- package/lib/module/decisions.js +85 -8
- package/lib/module/decisions.js.map +1 -1
- package/lib/module/init.js +5 -2
- package/lib/module/init.js.map +1 -1
- package/lib/typescript/config.d.ts +10 -0
- package/lib/typescript/config.d.ts.map +1 -1
- package/lib/typescript/decisions.d.ts +12 -0
- package/lib/typescript/decisions.d.ts.map +1 -1
- package/lib/typescript/init.d.ts +4 -1
- package/lib/typescript/init.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +205 -11
- package/src/decisions.ts +128 -10
- package/src/init.ts +16 -1
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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({
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
outcome: queued.outcome,
|
|
118
|
+
...(queued.metadata ? { metadata: queued.metadata } : {}),
|
|
119
|
+
}),
|
|
81
120
|
}, timeoutMs);
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
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: () => ({
|
|
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));
|