@ultdai/pulse 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -7,25 +7,32 @@ npm install @ultdai/pulse
7
7
  ```
8
8
 
9
9
  ```ts
10
- import { CloudflareSdkConfigurationCache, UnlimitedClient } from '@ultdai/pulse'
10
+ import {
11
+ CloudflareSdkConfigurationCache,
12
+ CloudflareSessionDecisionCache,
13
+ UnlimitedClient,
14
+ } from '@ultdai/pulse'
11
15
 
12
16
  export interface Env { ULTD_API_TOKEN: string }
13
17
 
18
+ let client: UnlimitedClient | undefined
19
+
14
20
  export default {
15
- async fetch(request: Request, env: Env) {
21
+ async fetch(request: Request, env: Env, context: ExecutionContext) {
16
22
  const session = getPlaybackSession(request) // stabilní ID a čas vzniku v Unixových milisekundách
17
- const client = new UnlimitedClient({
23
+ client ??= new UnlimitedClient({
18
24
  apiToken: env.ULTD_API_TOKEN,
19
25
  baseUrl: 'https://api.ultd.tivio.studio',
20
26
  cache: new CloudflareSdkConfigurationCache(caches.default),
27
+ sessionCache: new CloudflareSessionDecisionCache(caches.default),
21
28
  source: 'stream-worker',
22
29
  })
23
- await client.emit({
30
+ context.waitUntil(client.emit({
24
31
  occurredAtMs: Date.now(),
25
32
  organizationId: 'tivio',
26
33
  pulse: {
27
- type: 'stream.segment.requested',
28
- version: 3,
34
+ type: 'playback.session.playing',
35
+ version: 1,
29
36
  id: session.id,
30
37
  payload: {
31
38
  sessionStartedAtMs: session.startedAtMs,
@@ -33,7 +40,7 @@ export default {
33
40
  ...(session.channelId ? { channelId: session.channelId } : {}),
34
41
  },
35
42
  },
36
- })
43
+ }).catch((error) => console.error('ULTD pulse failed', error)))
37
44
  return new Response('ok')
38
45
  },
39
46
  }
@@ -41,6 +48,8 @@ export default {
41
48
 
42
49
  Klíč patří do serverového secretu, nikdy do klientského JavaScriptu. Konstruktor bez neprázdného `apiToken` skončí chybou ještě před síťovým voláním. Unlimited ověřuje klíč znovu na `GET /v1/sdk-config` i `POST /v1/pulses`; neplatný klíč nemůže zapsat puls.
43
50
 
44
- SDK dynamicky načítá seznam PulseTypes, jejich schémata, povolení sběru a sampling politiku při prvním `emit()`. Neznámý typ nebo nevalidní `pulse.payload` SDK odmítne lokálně. Vypnutý nebo nevzorkovaný puls neodešle. `occurredAtMs` a playback v3 `sessionStartedAtMs` jsou Unixové časy v milisekundách. Playback v2 se starším ISO polem `sessionStartedAt` zůstává podporován. Konfigurace má krátkou platnost a po jejím vypršení se obnovuje; pokud obnovení selže, puls se neodešle. Cloudflare cache je volitelná; bez ní SDK používá paměť instance.
51
+ SDK dynamicky načítá seznam PulseTypes, jejich schémata, povolení sběru a sampling politiku při prvním `emit()`. Neznámý typ nebo nevalidní `pulse.payload` SDK odmítne lokálně. Vypnutý nebo nevzorkovaný puls neodešle. `occurredAtMs` a `sessionStartedAtMs` jsou Unixové časy v milisekundách. Konfigurace má krátkou platnost a po jejím vypršení se obnovuje; pokud obnovení selže, puls se neodešle. Cloudflare cache je volitelná; bez ní SDK používá paměť instance.
52
+
53
+ U `playback.session.playing@1` SDK při prvním volání pro session odešle idempotentní `playback.session.started@1`. API vrátí rozhodnutí, zda se session dále měří. Vybraná session pak odešle heartbeat nejdříve po 60 sekundách nebo při změně videa; ostatní volání skončí lokálně. Po ztrátě cache SDK zopakuje start se stejným ID a obnoví původní rozhodnutí. Pevné ID session a čas začátku musí dodávat producent. API a Durable Object opakovaný start rozpoznají; cache je pouze optimalizace.
45
54
 
46
55
  SDK obsahuje interní kontrakty přibalené při buildu z monorepa. Aplikace instaluje jen `@ultdai/pulse` a jeho běžnou závislost `zod`; `@ultdai/contracts` se samostatně nevydává. Samotné zveřejnění SDK neposkytuje oprávnění k API.
@@ -156,6 +156,14 @@ export declare const PulseTypeDefinitionSchema: z.ZodObject<{
156
156
  reliable: "reliable";
157
157
  }>;
158
158
  id: z.ZodString;
159
+ lifecycle: z.ZodOptional<z.ZodObject<{
160
+ groupId: z.ZodString;
161
+ phase: z.ZodEnum<{
162
+ observe: "observe";
163
+ start: "start";
164
+ }>;
165
+ intervalMs: z.ZodOptional<z.ZodNumber>;
166
+ }, z.core.$strict>>;
159
167
  payloadSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
160
168
  requiresExplicitId: z.ZodOptional<z.ZodBoolean>;
161
169
  sampling: z.ZodOptional<z.ZodObject<{
@@ -170,6 +178,7 @@ export declare const PulseTypeDefinitionSchema: z.ZodObject<{
170
178
  }, z.core.$strict>;
171
179
  export declare const SignalReducerSchema: z.ZodEnum<{
172
180
  "distinct-per-window": "distinct-per-window";
181
+ lifecycle: "lifecycle";
173
182
  presence: "presence";
174
183
  }>;
175
184
  export declare const SignalTypeDefinitionSchema: z.ZodObject<{
@@ -179,6 +188,13 @@ export declare const SignalTypeDefinitionSchema: z.ZodObject<{
179
188
  version: z.ZodNumber;
180
189
  }, z.core.$strict>>;
181
190
  dimensions: z.ZodOptional<z.ZodArray<z.ZodString>>;
191
+ lifecycle: z.ZodOptional<z.ZodObject<{
192
+ intervalMs: z.ZodNumber;
193
+ scopeFields: z.ZodArray<z.ZodString>;
194
+ targetSampledPerScopeShard: z.ZodNumber;
195
+ timeoutMs: z.ZodNumber;
196
+ windowMs: z.ZodNumber;
197
+ }, z.core.$strict>>;
182
198
  partitionKey: z.ZodOptional<z.ZodString>;
183
199
  presence: z.ZodOptional<z.ZodObject<{
184
200
  immutableDimensions: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -188,6 +204,7 @@ export declare const SignalTypeDefinitionSchema: z.ZodObject<{
188
204
  }, z.core.$strict>>;
189
205
  reducer: z.ZodEnum<{
190
206
  "distinct-per-window": "distinct-per-window";
207
+ lifecycle: "lifecycle";
191
208
  presence: "presence";
192
209
  }>;
193
210
  shardCount: z.ZodNumber;
@@ -282,8 +299,98 @@ export declare const RoutedMetricPulseSchema: z.ZodObject<{
282
299
  windowStartAt: z.ZodOptional<z.ZodString>;
283
300
  }, z.core.$strict>;
284
301
  }, z.core.$strict>;
302
+ export declare const LifecyclePulseTargetSchema: z.ZodObject<{
303
+ generation: z.ZodNumber;
304
+ groupId: z.ZodString;
305
+ intervalMs: z.ZodNumber;
306
+ organizationId: z.ZodString;
307
+ ownerId: z.ZodString;
308
+ phase: z.ZodEnum<{
309
+ observe: "observe";
310
+ start: "start";
311
+ }>;
312
+ refs: z.ZodArray<z.ZodObject<{
313
+ signalId: z.ZodString;
314
+ dimension: z.ZodString;
315
+ }, z.core.$strict>>;
316
+ scopeId: z.ZodString;
317
+ sessionStartedAt: z.ZodString;
318
+ shardCount: z.ZodNumber;
319
+ shardIndex: z.ZodNumber;
320
+ targetSampledPerScopeShard: z.ZodNumber;
321
+ timeoutMs: z.ZodNumber;
322
+ windowMs: z.ZodNumber;
323
+ }, z.core.$strict>;
324
+ export declare const RoutedLifecyclePulseSchema: z.ZodObject<{
325
+ pulse: z.ZodObject<{
326
+ data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
327
+ eventId: z.ZodOptional<z.ZodString>;
328
+ id: z.ZodString;
329
+ organizationId: z.ZodString;
330
+ sampling: z.ZodDefault<z.ZodObject<{
331
+ applied: z.ZodBoolean;
332
+ appliedKeepProbability: z.ZodNumber;
333
+ effectiveKeepProbability: z.ZodNumber;
334
+ ingressApplied: z.ZodBoolean;
335
+ ingressKeepProbability: z.ZodNumber;
336
+ ingressMethod: z.ZodEnum<{
337
+ none: "none";
338
+ "pulse-hash-v1": "pulse-hash-v1";
339
+ }>;
340
+ knownKeepProbability: z.ZodNullable<z.ZodNumber>;
341
+ method: z.ZodEnum<{
342
+ none: "none";
343
+ "pulse-hash-v1": "pulse-hash-v1";
344
+ }>;
345
+ policyId: z.ZodNullable<z.ZodString>;
346
+ policyRevision: z.ZodNullable<z.ZodNumber>;
347
+ unit: z.ZodEnum<{
348
+ member: "member";
349
+ pulse: "pulse";
350
+ }>;
351
+ }, z.core.$strict>>;
352
+ source: z.ZodString;
353
+ subject: z.ZodOptional<z.ZodObject<{
354
+ id: z.ZodString;
355
+ type: z.ZodString;
356
+ }, z.core.$strip>>;
357
+ type: z.ZodString;
358
+ typeVersion: z.ZodNumber;
359
+ occurredAt: z.ZodString;
360
+ receivedAt: z.ZodString;
361
+ }, z.core.$strict>;
362
+ target: z.ZodObject<{
363
+ generation: z.ZodNumber;
364
+ groupId: z.ZodString;
365
+ intervalMs: z.ZodNumber;
366
+ organizationId: z.ZodString;
367
+ ownerId: z.ZodString;
368
+ phase: z.ZodEnum<{
369
+ observe: "observe";
370
+ start: "start";
371
+ }>;
372
+ refs: z.ZodArray<z.ZodObject<{
373
+ signalId: z.ZodString;
374
+ dimension: z.ZodString;
375
+ }, z.core.$strict>>;
376
+ scopeId: z.ZodString;
377
+ sessionStartedAt: z.ZodString;
378
+ shardCount: z.ZodNumber;
379
+ shardIndex: z.ZodNumber;
380
+ targetSampledPerScopeShard: z.ZodNumber;
381
+ timeoutMs: z.ZodNumber;
382
+ windowMs: z.ZodNumber;
383
+ }, z.core.$strict>;
384
+ }, z.core.$strict>;
385
+ export declare const LifecycleDecisionSchema: z.ZodObject<{
386
+ accepted: z.ZodBoolean;
387
+ nextHeartbeatAtMs: z.ZodNumber;
388
+ probability: z.ZodNumber;
389
+ selected: z.ZodBoolean;
390
+ }, z.core.$strict>;
285
391
  export declare const MetricSnapshotSchema: z.ZodObject<{
286
392
  estimatedCount: z.ZodNumber;
393
+ varianceEstimate: z.ZodOptional<z.ZodNumber>;
287
394
  generation: z.ZodNumber;
288
395
  kind: z.ZodEnum<{
289
396
  "distinct-per-window": "distinct-per-window";
@@ -322,6 +429,14 @@ export declare const SdkPulseTypeSchema: z.ZodObject<{
322
429
  reliable: "reliable";
323
430
  }>;
324
431
  id: z.ZodString;
432
+ lifecycle: z.ZodOptional<z.ZodObject<{
433
+ groupId: z.ZodString;
434
+ phase: z.ZodEnum<{
435
+ observe: "observe";
436
+ start: "start";
437
+ }>;
438
+ intervalMs: z.ZodOptional<z.ZodNumber>;
439
+ }, z.core.$strict>>;
325
440
  payloadSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
326
441
  requiresExplicitId: z.ZodOptional<z.ZodBoolean>;
327
442
  sampling: z.ZodOptional<z.ZodObject<{
@@ -359,6 +474,14 @@ export declare const SdkConfigurationSchema: z.ZodObject<{
359
474
  reliable: "reliable";
360
475
  }>;
361
476
  id: z.ZodString;
477
+ lifecycle: z.ZodOptional<z.ZodObject<{
478
+ groupId: z.ZodString;
479
+ phase: z.ZodEnum<{
480
+ observe: "observe";
481
+ start: "start";
482
+ }>;
483
+ intervalMs: z.ZodOptional<z.ZodNumber>;
484
+ }, z.core.$strict>>;
362
485
  payloadSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
363
486
  requiresExplicitId: z.ZodOptional<z.ZodBoolean>;
364
487
  sampling: z.ZodOptional<z.ZodObject<{
@@ -395,6 +518,7 @@ export declare const LivePulseTargetSchema: z.ZodObject<{
395
518
  organizationId: z.ZodString;
396
519
  reducer: z.ZodEnum<{
397
520
  "distinct-per-window": "distinct-per-window";
521
+ lifecycle: "lifecycle";
398
522
  presence: "presence";
399
523
  }>;
400
524
  shardId: z.ZodString;
@@ -447,6 +571,7 @@ export declare const RoutedLivePulseSchema: z.ZodObject<{
447
571
  organizationId: z.ZodString;
448
572
  reducer: z.ZodEnum<{
449
573
  "distinct-per-window": "distinct-per-window";
574
+ lifecycle: "lifecycle";
450
575
  presence: "presence";
451
576
  }>;
452
577
  shardId: z.ZodString;
@@ -542,3 +667,6 @@ export type LiveShardSnapshot = z.infer<typeof LiveShardSnapshotSchema>;
542
667
  export type MetricPulseTarget = z.infer<typeof MetricPulseTargetSchema>;
543
668
  export type MetricSnapshot = z.infer<typeof MetricSnapshotSchema>;
544
669
  export type RoutedMetricPulse = z.infer<typeof RoutedMetricPulseSchema>;
670
+ export type RoutedLifecyclePulse = z.infer<typeof RoutedLifecyclePulseSchema>;
671
+ export type LifecyclePulseTarget = z.infer<typeof LifecyclePulseTargetSchema>;
672
+ export type LifecycleDecision = z.infer<typeof LifecycleDecisionSchema>;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import type { PulseSubmission } from './contracts/index.js';
2
2
  import type { SdkConfigurationCache } from './configurationCache.js';
3
+ import type { SessionDecisionCache } from './sessionDecisionCache.js';
3
4
  export { CloudflareSdkConfigurationCache, MemorySdkConfigurationCache } from './configurationCache.js';
4
5
  export type { CachedSdkConfiguration, SdkConfigurationCache } from './configurationCache.js';
6
+ export { CloudflareSessionDecisionCache, MemorySessionDecisionCache } from './sessionDecisionCache.js';
7
+ export type { SessionDecision, SessionDecisionCache } from './sessionDecisionCache.js';
5
8
  export { CloudflareSamplingPolicyCache, MemorySamplingPolicyCache } from './cache.js';
6
9
  export type { CacheApiLike, SamplingPolicyCache } from './cache.js';
7
10
  export type SdkPulseInput = Omit<PulseSubmission, 'source' | 'sampling' | 'pulse'> & {
@@ -14,6 +17,7 @@ export interface UnlimitedClientOptions {
14
17
  baseUrl: string;
15
18
  source: string;
16
19
  cache?: SdkConfigurationCache;
20
+ sessionCache?: SessionDecisionCache;
17
21
  fetch?: typeof fetch;
18
22
  now?: () => Date;
19
23
  randomUUID?: () => string;
@@ -23,12 +27,18 @@ export interface EmitResult {
23
27
  disabled?: boolean;
24
28
  pulseId: string;
25
29
  sampledOut: boolean;
30
+ selected?: boolean;
31
+ throttled?: boolean;
32
+ probability?: number;
33
+ nextHeartbeatAtMs?: number;
26
34
  }
27
35
  export declare class UnlimitedClient {
28
36
  private readonly apiToken;
29
37
  private readonly baseUrl;
30
38
  private readonly source;
31
39
  private readonly cache;
40
+ private readonly sessionCache;
41
+ private readonly sessionMemory;
32
42
  private readonly fetcher;
33
43
  private readonly now;
34
44
  private readonly randomUUID;
@@ -39,5 +49,8 @@ export declare class UnlimitedClient {
39
49
  private install;
40
50
  private refresh;
41
51
  private configuration;
52
+ private decisionKey;
53
+ private getDecision;
54
+ private putDecision;
42
55
  emit(input: SdkPulseInput): Promise<EmitResult>;
43
56
  }
package/dist/index.js CHANGED
@@ -178,6 +178,11 @@ var PulseDeliverySchema = z2.enum(["live", "reliable"]);
178
178
  var PulseTypeDefinitionSchema = z2.object({
179
179
  delivery: PulseDeliverySchema,
180
180
  id: z2.string().trim().min(1).max(100),
181
+ lifecycle: z2.object({
182
+ groupId: z2.string().trim().min(1).max(100),
183
+ phase: z2.enum(["start", "observe"]),
184
+ intervalMs: z2.number().int().min(1e3).max(864e5).optional()
185
+ }).strict().optional(),
181
186
  payloadSchema: z2.record(z2.string(), z2.unknown()),
182
187
  requiresExplicitId: z2.boolean().optional(),
183
188
  sampling: z2.object({
@@ -196,8 +201,11 @@ var PulseTypeDefinitionSchema = z2.object({
196
201
  if (definition.sampling?.unit === "pulse" && definition.sampling.keyField !== null) {
197
202
  context.addIssue({ code: "custom", path: ["sampling", "keyField"], message: "Pulse sampling must not define keyField." });
198
203
  }
204
+ if (definition.lifecycle && (definition.samplingAllowed || !definition.requiresExplicitId || definition.delivery !== "live" || definition.lifecycle.phase === "observe" !== Boolean(definition.lifecycle.intervalMs))) {
205
+ context.addIssue({ code: "custom", path: ["lifecycle"], message: "Lifecycle pulses need explicit IDs, live delivery, and an interval only for observations." });
206
+ }
199
207
  });
200
- var SignalReducerSchema = z2.enum(["presence", "distinct-per-window"]);
208
+ var SignalReducerSchema = z2.enum(["presence", "distinct-per-window", "lifecycle"]);
201
209
  var SignalTypeDefinitionSchema = z2.object({
202
210
  id: z2.string().trim().min(1).max(100),
203
211
  inputPulseTypes: z2.array(z2.object({
@@ -205,6 +213,13 @@ var SignalTypeDefinitionSchema = z2.object({
205
213
  version: z2.number().int().positive()
206
214
  }).strict()).min(1),
207
215
  dimensions: z2.array(z2.string().trim().min(1).max(100)).optional(),
216
+ lifecycle: z2.object({
217
+ intervalMs: z2.number().int().min(1e3).max(864e5),
218
+ scopeFields: z2.array(z2.string().trim().min(1).max(100)).min(1),
219
+ targetSampledPerScopeShard: z2.number().int().positive(),
220
+ timeoutMs: z2.number().int().min(1e3).max(864e5),
221
+ windowMs: z2.number().int().min(1e3).max(864e5)
222
+ }).strict().optional(),
208
223
  partitionKey: z2.string().trim().min(1).max(200).optional(),
209
224
  presence: z2.object({
210
225
  immutableDimensions: z2.array(z2.string().trim().min(1).max(100)).optional(),
@@ -228,6 +243,9 @@ var SignalTypeDefinitionSchema = z2.object({
228
243
  if (definition.reducer === "distinct-per-window" && !definition.windowMs) {
229
244
  context.addIssue({ code: "custom", path: ["windowMs"], message: "Windowed distinct needs a window." });
230
245
  }
246
+ if (definition.reducer === "lifecycle" && (!definition.lifecycle || !definition.partitionKey || !definition.dimensions?.length)) {
247
+ context.addIssue({ code: "custom", path: ["lifecycle"], message: "Lifecycle needs a configuration, partition key and dimensions." });
248
+ }
231
249
  const schedule = definition.shardSchedule ?? [];
232
250
  if (schedule.length > 0 && (!definition.partitionKey || !definition.dimensions?.length)) {
233
251
  context.addIssue({ code: "custom", path: ["partitionKey"], message: "Scheduled routing needs partitionKey and dimensions." });
@@ -235,9 +253,10 @@ var SignalTypeDefinitionSchema = z2.object({
235
253
  if (schedule.length > 0 && definition.reducer === "presence" && !definition.presence?.startedAtField) {
236
254
  context.addIssue({ code: "custom", path: ["presence", "startedAtField"], message: "Scheduled presence needs a stable start time." });
237
255
  }
238
- if (definition.windowMs) {
256
+ const scheduledWindowMs = definition.windowMs ?? definition.lifecycle?.windowMs;
257
+ if (scheduledWindowMs) {
239
258
  for (let index = 0; index < schedule.length; index += 1) {
240
- if (Date.parse(schedule[index].effectiveFrom) % definition.windowMs !== 0) {
259
+ if (Date.parse(schedule[index].effectiveFrom) % scheduledWindowMs !== 0) {
241
260
  context.addIssue({ code: "custom", path: ["shardSchedule", index], message: "Window generation must start on a window boundary." });
242
261
  }
243
262
  }
@@ -282,8 +301,39 @@ var RoutedMetricPulseSchema = z2.object({
282
301
  pulse: RawPulseSchema,
283
302
  target: MetricPulseTargetSchema
284
303
  }).strict();
304
+ var LifecyclePulseTargetSchema = z2.object({
305
+ generation: z2.number().int().positive(),
306
+ groupId: z2.string().trim().min(1).max(100),
307
+ intervalMs: z2.number().int().positive(),
308
+ organizationId: z2.string().trim().min(1).max(100),
309
+ ownerId: z2.string().trim().min(1).max(500),
310
+ phase: z2.enum(["start", "observe"]),
311
+ refs: z2.array(MetricSignalRefSchema).min(1),
312
+ scopeId: z2.string().trim().min(1).max(500),
313
+ sessionStartedAt: isoDateTime,
314
+ shardCount: z2.number().int().min(1).max(65536),
315
+ shardIndex: z2.number().int().nonnegative(),
316
+ targetSampledPerScopeShard: z2.number().int().positive(),
317
+ timeoutMs: z2.number().int().positive(),
318
+ windowMs: z2.number().int().positive()
319
+ }).strict().superRefine((target, context) => {
320
+ if (target.shardIndex >= target.shardCount) {
321
+ context.addIssue({ code: "custom", path: ["shardIndex"], message: "Shard index exceeds shard count." });
322
+ }
323
+ });
324
+ var RoutedLifecyclePulseSchema = z2.object({
325
+ pulse: RawPulseSchema,
326
+ target: LifecyclePulseTargetSchema
327
+ }).strict();
328
+ var LifecycleDecisionSchema = z2.object({
329
+ accepted: z2.boolean(),
330
+ nextHeartbeatAtMs: z2.number().int().nonnegative(),
331
+ probability: z2.number().positive().max(1),
332
+ selected: z2.boolean()
333
+ }).strict();
285
334
  var MetricSnapshotSchema = z2.object({
286
335
  estimatedCount: z2.number().finite().nonnegative(),
336
+ varianceEstimate: z2.number().finite().nonnegative().optional(),
287
337
  generation: z2.number().int().positive(),
288
338
  kind: z2.enum(["presence", "distinct-per-window"]),
289
339
  observedAt: isoDateTime,
@@ -435,6 +485,47 @@ var CloudflareSdkConfigurationCache = class {
435
485
  }
436
486
  };
437
487
 
488
+ // src/sessionDecisionCache.ts
489
+ var MemorySessionDecisionCache = class {
490
+ constructor(maxEntries = 1e4) {
491
+ this.maxEntries = maxEntries;
492
+ }
493
+ maxEntries;
494
+ values = /* @__PURE__ */ new Map();
495
+ async get(key) {
496
+ return this.values.get(key);
497
+ }
498
+ async set(key, decision) {
499
+ this.values.delete(key);
500
+ this.values.set(key, decision);
501
+ if (this.values.size > this.maxEntries) this.values.delete(this.values.keys().next().value);
502
+ }
503
+ };
504
+ var CloudflareSessionDecisionCache = class {
505
+ constructor(cache, namespace = "https://session-decision-cache.ultd.invalid") {
506
+ this.cache = cache;
507
+ this.namespace = namespace;
508
+ }
509
+ cache;
510
+ namespace;
511
+ request(key) {
512
+ return new Request(`${this.namespace}/${key}`);
513
+ }
514
+ async get(key) {
515
+ const response = await this.cache.match(this.request(key));
516
+ if (!response) return void 0;
517
+ const value = await response.json();
518
+ if (typeof value.expiresAtMs !== "number" || typeof value.nextHeartbeatAtMs !== "number" || typeof value.probability !== "number" || value.probability <= 0 || value.probability > 1 || typeof value.selected !== "boolean") return void 0;
519
+ return value;
520
+ }
521
+ async set(key, decision) {
522
+ const seconds = Math.max(1, Math.floor((decision.expiresAtMs - Date.now()) / 1e3));
523
+ await this.cache.put(this.request(key), new Response(JSON.stringify(decision), {
524
+ headers: { "cache-control": `public, max-age=${seconds}`, "content-type": "application/json" }
525
+ }));
526
+ }
527
+ };
528
+
438
529
  // src/cache.ts
439
530
  var MemorySamplingPolicyCache = class {
440
531
  policies = /* @__PURE__ */ new Map();
@@ -499,6 +590,8 @@ var UnlimitedClient = class {
499
590
  baseUrl;
500
591
  source;
501
592
  cache;
593
+ sessionCache;
594
+ sessionMemory = new MemorySessionDecisionCache();
502
595
  fetcher;
503
596
  now;
504
597
  randomUUID;
@@ -516,6 +609,7 @@ var UnlimitedClient = class {
516
609
  this.baseUrl = options.baseUrl.replace(/\/$/, "");
517
610
  this.source = options.source;
518
611
  this.cache = options.cache ?? new MemorySdkConfigurationCache();
612
+ this.sessionCache = options.sessionCache ?? this.sessionMemory;
519
613
  this.fetcher = options.fetch ?? fetch;
520
614
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
521
615
  this.randomUUID = options.randomUUID ?? (() => crypto.randomUUID());
@@ -584,6 +678,28 @@ var UnlimitedClient = class {
584
678
  });
585
679
  return this.refreshing;
586
680
  }
681
+ async decisionKey(groupId, organizationId, sessionId) {
682
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(
683
+ `${await this.cacheKey}\0${groupId}\0${organizationId}\0${sessionId}`
684
+ ));
685
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
686
+ }
687
+ async getDecision(key) {
688
+ const memory = await this.sessionMemory.get(key);
689
+ if (memory && memory.expiresAtMs > this.now().getTime()) return memory;
690
+ const cached = await this.sessionCache.get(key).catch(() => void 0);
691
+ if (cached && cached.expiresAtMs > this.now().getTime()) {
692
+ await this.sessionMemory.set(key, cached);
693
+ return cached;
694
+ }
695
+ return void 0;
696
+ }
697
+ async putDecision(key, decision) {
698
+ await this.sessionMemory.set(key, decision);
699
+ if (this.sessionCache !== this.sessionMemory) {
700
+ await this.sessionCache.set(key, decision).catch(() => void 0);
701
+ }
702
+ }
587
703
  async emit(input) {
588
704
  const loaded = await this.configuration();
589
705
  const type = loaded.types.get(typeKey(input.pulse.type, input.pulse.version));
@@ -602,9 +718,36 @@ var UnlimitedClient = class {
602
718
  if (!type.entry.enabled) {
603
719
  return { accepted: false, disabled: true, pulseId: id, sampledOut: false };
604
720
  }
721
+ const lifecycle = type.entry.definition.lifecycle;
722
+ const decisionKey = lifecycle ? await this.decisionKey(lifecycle.groupId, input.organizationId, id) : void 0;
723
+ if (lifecycle?.phase === "observe" && decisionKey) {
724
+ const decision = await this.getDecision(decisionKey);
725
+ if (!decision) {
726
+ const start = [...loaded.types.values()].find((candidate) => candidate.entry.definition.lifecycle?.groupId === lifecycle.groupId && candidate.entry.definition.lifecycle.phase === "start");
727
+ if (!start) throw new Error(`Lifecycle group ${lifecycle.groupId} has no start PulseType.`);
728
+ return this.emit({
729
+ ...input,
730
+ pulse: { ...input.pulse, type: start.entry.definition.id, version: start.entry.definition.version }
731
+ });
732
+ }
733
+ if (decision && !decision.selected) {
734
+ return { accepted: false, pulseId: id, sampledOut: true, selected: false, probability: decision.probability };
735
+ }
736
+ if (decision && this.now().getTime() < decision.nextHeartbeatAtMs && decision.lastVideoId === payload.videoId) {
737
+ return {
738
+ accepted: false,
739
+ pulseId: id,
740
+ sampledOut: false,
741
+ throttled: true,
742
+ selected: true,
743
+ probability: decision.probability,
744
+ nextHeartbeatAtMs: decision.nextHeartbeatAtMs
745
+ };
746
+ }
747
+ }
605
748
  const policy = type.entry.samplingPolicy;
606
749
  let sampling = NoSamplingContext;
607
- if (policy) {
750
+ if (policy && !lifecycle) {
608
751
  sampling = {
609
752
  applied: policy.keepProbability < 1,
610
753
  appliedKeepProbability: policy.keepProbability,
@@ -639,6 +782,26 @@ var UnlimitedClient = class {
639
782
  });
640
783
  if (!response.ok) throw new Error(`Pulse request failed with ${response.status}.`);
641
784
  const body = await response.json();
785
+ if (lifecycle && decisionKey) {
786
+ if (typeof body.selected !== "boolean" || typeof body.probability !== "number" || body.probability <= 0 || body.probability > 1 || typeof body.nextHeartbeatAtMs !== "number") {
787
+ throw new Error("Lifecycle response did not contain a valid session decision.");
788
+ }
789
+ await this.putDecision(decisionKey, {
790
+ selected: body.selected,
791
+ probability: body.probability,
792
+ nextHeartbeatAtMs: body.nextHeartbeatAtMs,
793
+ lastVideoId: body.selected && typeof payload.videoId === "string" ? payload.videoId : void 0,
794
+ expiresAtMs: this.now().getTime() + 24 * 60 * 60 * 1e3
795
+ });
796
+ return {
797
+ accepted: body.accepted !== false,
798
+ pulseId: id,
799
+ sampledOut: lifecycle.phase === "observe" && !body.selected,
800
+ selected: body.selected,
801
+ probability: body.probability,
802
+ nextHeartbeatAtMs: body.nextHeartbeatAtMs
803
+ };
804
+ }
642
805
  return {
643
806
  accepted: body.accepted !== false,
644
807
  ...body.disabled === true ? { disabled: true } : {},
@@ -650,7 +813,9 @@ var UnlimitedClient = class {
650
813
  export {
651
814
  CloudflareSamplingPolicyCache,
652
815
  CloudflareSdkConfigurationCache,
816
+ CloudflareSessionDecisionCache,
653
817
  MemorySamplingPolicyCache,
654
818
  MemorySdkConfigurationCache,
819
+ MemorySessionDecisionCache,
655
820
  UnlimitedClient
656
821
  };
@@ -0,0 +1,28 @@
1
+ import type { CacheApiLike } from './cache.js';
2
+ export interface SessionDecision {
3
+ expiresAtMs: number;
4
+ lastVideoId?: string;
5
+ nextHeartbeatAtMs: number;
6
+ probability: number;
7
+ selected: boolean;
8
+ }
9
+ export interface SessionDecisionCache {
10
+ get(key: string): Promise<SessionDecision | undefined>;
11
+ set(key: string, decision: SessionDecision): Promise<void>;
12
+ }
13
+ export declare class MemorySessionDecisionCache implements SessionDecisionCache {
14
+ private readonly maxEntries;
15
+ private readonly values;
16
+ constructor(maxEntries?: number);
17
+ get(key: string): Promise<SessionDecision | undefined>;
18
+ set(key: string, decision: SessionDecision): Promise<void>;
19
+ }
20
+ /** Optional L2 cache; the Durable Object remains authoritative on a miss. */
21
+ export declare class CloudflareSessionDecisionCache implements SessionDecisionCache {
22
+ private readonly cache;
23
+ private readonly namespace;
24
+ constructor(cache: CacheApiLike, namespace?: string);
25
+ private request;
26
+ get(key: string): Promise<SessionDecision | undefined>;
27
+ set(key: string, decision: SessionDecision): Promise<void>;
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultdai/pulse",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "files": ["dist", "README.md"],
6
6
  "publishConfig": { "access": "public" },