@ultdai/pulse 0.1.0 → 0.3.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<{
@@ -169,16 +177,50 @@ export declare const PulseTypeDefinitionSchema: z.ZodObject<{
169
177
  version: z.ZodNumber;
170
178
  }, z.core.$strict>;
171
179
  export declare const SignalReducerSchema: z.ZodEnum<{
180
+ "additive-window": "additive-window";
172
181
  "distinct-per-window": "distinct-per-window";
182
+ lifecycle: "lifecycle";
173
183
  presence: "presence";
174
184
  }>;
175
185
  export declare const SignalTypeDefinitionSchema: z.ZodObject<{
186
+ additive: z.ZodOptional<z.ZodObject<{
187
+ baseScopeFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
188
+ cohortAtField: z.ZodString;
189
+ dedupeFields: z.ZodArray<z.ZodString>;
190
+ dimensionSets: z.ZodArray<z.ZodObject<{
191
+ kind: z.ZodString;
192
+ fields: z.ZodArray<z.ZodString>;
193
+ }, z.core.$strict>>;
194
+ latenessMs: z.ZodNumber;
195
+ metricRules: z.ZodArray<z.ZodObject<{
196
+ name: z.ZodString;
197
+ op: z.ZodEnum<{
198
+ count: "count";
199
+ sum: "sum";
200
+ }>;
201
+ unitField: z.ZodOptional<z.ZodString>;
202
+ valueField: z.ZodOptional<z.ZodString>;
203
+ when: z.ZodOptional<z.ZodObject<{
204
+ field: z.ZodString;
205
+ equals: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
206
+ }, z.core.$strict>>;
207
+ }, z.core.$strict>>;
208
+ primaryMetric: z.ZodString;
209
+ stableScopeField: z.ZodOptional<z.ZodString>;
210
+ }, z.core.$strict>>;
176
211
  id: z.ZodString;
177
212
  inputPulseTypes: z.ZodArray<z.ZodObject<{
178
213
  id: z.ZodString;
179
214
  version: z.ZodNumber;
180
215
  }, z.core.$strict>>;
181
216
  dimensions: z.ZodOptional<z.ZodArray<z.ZodString>>;
217
+ lifecycle: z.ZodOptional<z.ZodObject<{
218
+ intervalMs: z.ZodNumber;
219
+ scopeFields: z.ZodArray<z.ZodString>;
220
+ targetSampledPerScopeShard: z.ZodNumber;
221
+ timeoutMs: z.ZodNumber;
222
+ windowMs: z.ZodNumber;
223
+ }, z.core.$strict>>;
182
224
  partitionKey: z.ZodOptional<z.ZodString>;
183
225
  presence: z.ZodOptional<z.ZodObject<{
184
226
  immutableDimensions: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -187,7 +229,9 @@ export declare const SignalTypeDefinitionSchema: z.ZodObject<{
187
229
  timeoutMs: z.ZodNumber;
188
230
  }, z.core.$strict>>;
189
231
  reducer: z.ZodEnum<{
232
+ "additive-window": "additive-window";
190
233
  "distinct-per-window": "distinct-per-window";
234
+ lifecycle: "lifecycle";
191
235
  presence: "presence";
192
236
  }>;
193
237
  shardCount: z.ZodNumber;
@@ -212,6 +256,7 @@ export declare const MetricPulseTargetSchema: z.ZodObject<{
212
256
  refs: z.ZodArray<z.ZodObject<{
213
257
  signalId: z.ZodString;
214
258
  dimension: z.ZodString;
259
+ scope: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
215
260
  }, z.core.$strict>>;
216
261
  sessionStartedAt: z.ZodString;
217
262
  shardCount: z.ZodNumber;
@@ -272,6 +317,7 @@ export declare const RoutedMetricPulseSchema: z.ZodObject<{
272
317
  refs: z.ZodArray<z.ZodObject<{
273
318
  signalId: z.ZodString;
274
319
  dimension: z.ZodString;
320
+ scope: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
275
321
  }, z.core.$strict>>;
276
322
  sessionStartedAt: z.ZodString;
277
323
  shardCount: z.ZodNumber;
@@ -282,19 +328,196 @@ export declare const RoutedMetricPulseSchema: z.ZodObject<{
282
328
  windowStartAt: z.ZodOptional<z.ZodString>;
283
329
  }, z.core.$strict>;
284
330
  }, z.core.$strict>;
331
+ /** A pulse carries a bounded vector of exact, non-sampled metric additions. */
332
+ export declare const AdditivePulseTargetSchema: z.ZodObject<{
333
+ dedupeKey: z.ZodString;
334
+ generation: z.ZodNumber;
335
+ latenessMs: z.ZodNumber;
336
+ metrics: z.ZodRecord<z.ZodString, z.ZodNumber>;
337
+ organizationId: z.ZodString;
338
+ ownerId: z.ZodString;
339
+ primaryMetric: z.ZodString;
340
+ refs: z.ZodArray<z.ZodObject<{
341
+ signalId: z.ZodString;
342
+ dimension: z.ZodString;
343
+ scope: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
344
+ }, z.core.$strict>>;
345
+ shardCount: z.ZodNumber;
346
+ shardIndex: z.ZodNumber;
347
+ signalTypeId: z.ZodString;
348
+ stableScopeKey: z.ZodOptional<z.ZodString>;
349
+ windowEndAt: z.ZodString;
350
+ windowStartAt: z.ZodString;
351
+ }, z.core.$strict>;
352
+ export declare const RoutedAdditivePulseSchema: z.ZodObject<{
353
+ pulse: z.ZodObject<{
354
+ data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
355
+ eventId: z.ZodOptional<z.ZodString>;
356
+ id: z.ZodString;
357
+ organizationId: z.ZodString;
358
+ sampling: z.ZodDefault<z.ZodObject<{
359
+ applied: z.ZodBoolean;
360
+ appliedKeepProbability: z.ZodNumber;
361
+ effectiveKeepProbability: z.ZodNumber;
362
+ ingressApplied: z.ZodBoolean;
363
+ ingressKeepProbability: z.ZodNumber;
364
+ ingressMethod: z.ZodEnum<{
365
+ none: "none";
366
+ "pulse-hash-v1": "pulse-hash-v1";
367
+ }>;
368
+ knownKeepProbability: z.ZodNullable<z.ZodNumber>;
369
+ method: z.ZodEnum<{
370
+ none: "none";
371
+ "pulse-hash-v1": "pulse-hash-v1";
372
+ }>;
373
+ policyId: z.ZodNullable<z.ZodString>;
374
+ policyRevision: z.ZodNullable<z.ZodNumber>;
375
+ unit: z.ZodEnum<{
376
+ member: "member";
377
+ pulse: "pulse";
378
+ }>;
379
+ }, z.core.$strict>>;
380
+ source: z.ZodString;
381
+ subject: z.ZodOptional<z.ZodObject<{
382
+ id: z.ZodString;
383
+ type: z.ZodString;
384
+ }, z.core.$strip>>;
385
+ type: z.ZodString;
386
+ typeVersion: z.ZodNumber;
387
+ occurredAt: z.ZodString;
388
+ receivedAt: z.ZodString;
389
+ }, z.core.$strict>;
390
+ target: z.ZodObject<{
391
+ dedupeKey: z.ZodString;
392
+ generation: z.ZodNumber;
393
+ latenessMs: z.ZodNumber;
394
+ metrics: z.ZodRecord<z.ZodString, z.ZodNumber>;
395
+ organizationId: z.ZodString;
396
+ ownerId: z.ZodString;
397
+ primaryMetric: z.ZodString;
398
+ refs: z.ZodArray<z.ZodObject<{
399
+ signalId: z.ZodString;
400
+ dimension: z.ZodString;
401
+ scope: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
402
+ }, z.core.$strict>>;
403
+ shardCount: z.ZodNumber;
404
+ shardIndex: z.ZodNumber;
405
+ signalTypeId: z.ZodString;
406
+ stableScopeKey: z.ZodOptional<z.ZodString>;
407
+ windowEndAt: z.ZodString;
408
+ windowStartAt: z.ZodString;
409
+ }, z.core.$strict>;
410
+ }, z.core.$strict>;
411
+ export declare const LifecyclePulseTargetSchema: z.ZodObject<{
412
+ generation: z.ZodNumber;
413
+ groupId: z.ZodString;
414
+ intervalMs: z.ZodNumber;
415
+ organizationId: z.ZodString;
416
+ ownerId: z.ZodString;
417
+ phase: z.ZodEnum<{
418
+ observe: "observe";
419
+ start: "start";
420
+ }>;
421
+ refs: z.ZodArray<z.ZodObject<{
422
+ signalId: z.ZodString;
423
+ dimension: z.ZodString;
424
+ scope: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
425
+ }, z.core.$strict>>;
426
+ scopeId: z.ZodString;
427
+ sessionStartedAt: z.ZodString;
428
+ shardCount: z.ZodNumber;
429
+ shardIndex: z.ZodNumber;
430
+ targetSampledPerScopeShard: z.ZodNumber;
431
+ timeoutMs: z.ZodNumber;
432
+ windowMs: z.ZodNumber;
433
+ }, z.core.$strict>;
434
+ export declare const RoutedLifecyclePulseSchema: z.ZodObject<{
435
+ pulse: z.ZodObject<{
436
+ data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
437
+ eventId: z.ZodOptional<z.ZodString>;
438
+ id: z.ZodString;
439
+ organizationId: z.ZodString;
440
+ sampling: z.ZodDefault<z.ZodObject<{
441
+ applied: z.ZodBoolean;
442
+ appliedKeepProbability: z.ZodNumber;
443
+ effectiveKeepProbability: z.ZodNumber;
444
+ ingressApplied: z.ZodBoolean;
445
+ ingressKeepProbability: z.ZodNumber;
446
+ ingressMethod: z.ZodEnum<{
447
+ none: "none";
448
+ "pulse-hash-v1": "pulse-hash-v1";
449
+ }>;
450
+ knownKeepProbability: z.ZodNullable<z.ZodNumber>;
451
+ method: z.ZodEnum<{
452
+ none: "none";
453
+ "pulse-hash-v1": "pulse-hash-v1";
454
+ }>;
455
+ policyId: z.ZodNullable<z.ZodString>;
456
+ policyRevision: z.ZodNullable<z.ZodNumber>;
457
+ unit: z.ZodEnum<{
458
+ member: "member";
459
+ pulse: "pulse";
460
+ }>;
461
+ }, z.core.$strict>>;
462
+ source: z.ZodString;
463
+ subject: z.ZodOptional<z.ZodObject<{
464
+ id: z.ZodString;
465
+ type: z.ZodString;
466
+ }, z.core.$strip>>;
467
+ type: z.ZodString;
468
+ typeVersion: z.ZodNumber;
469
+ occurredAt: z.ZodString;
470
+ receivedAt: z.ZodString;
471
+ }, z.core.$strict>;
472
+ target: z.ZodObject<{
473
+ generation: z.ZodNumber;
474
+ groupId: z.ZodString;
475
+ intervalMs: z.ZodNumber;
476
+ organizationId: z.ZodString;
477
+ ownerId: z.ZodString;
478
+ phase: z.ZodEnum<{
479
+ observe: "observe";
480
+ start: "start";
481
+ }>;
482
+ refs: z.ZodArray<z.ZodObject<{
483
+ signalId: z.ZodString;
484
+ dimension: z.ZodString;
485
+ scope: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
486
+ }, z.core.$strict>>;
487
+ scopeId: z.ZodString;
488
+ sessionStartedAt: z.ZodString;
489
+ shardCount: z.ZodNumber;
490
+ shardIndex: z.ZodNumber;
491
+ targetSampledPerScopeShard: z.ZodNumber;
492
+ timeoutMs: z.ZodNumber;
493
+ windowMs: z.ZodNumber;
494
+ }, z.core.$strict>;
495
+ }, z.core.$strict>;
496
+ export declare const LifecycleDecisionSchema: z.ZodObject<{
497
+ accepted: z.ZodBoolean;
498
+ nextHeartbeatAtMs: z.ZodNumber;
499
+ probability: z.ZodNumber;
500
+ selected: z.ZodBoolean;
501
+ }, z.core.$strict>;
285
502
  export declare const MetricSnapshotSchema: z.ZodObject<{
286
503
  estimatedCount: z.ZodNumber;
504
+ varianceEstimate: z.ZodOptional<z.ZodNumber>;
287
505
  generation: z.ZodNumber;
288
506
  kind: z.ZodEnum<{
507
+ "additive-window": "additive-window";
289
508
  "distinct-per-window": "distinct-per-window";
290
509
  presence: "presence";
291
510
  }>;
511
+ latenessMs: z.ZodOptional<z.ZodNumber>;
512
+ metrics: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
292
513
  observedAt: z.ZodString;
293
514
  organizationId: z.ZodString;
294
515
  originId: z.ZodString;
295
516
  recovering: z.ZodBoolean;
296
517
  revision: z.ZodNumber;
297
518
  sampledCount: z.ZodNumber;
519
+ scope: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
520
+ scopeKind: z.ZodOptional<z.ZodString>;
298
521
  shardCount: z.ZodNumber;
299
522
  signalId: z.ZodString;
300
523
  signalTypeId: z.ZodString;
@@ -322,6 +545,14 @@ export declare const SdkPulseTypeSchema: z.ZodObject<{
322
545
  reliable: "reliable";
323
546
  }>;
324
547
  id: z.ZodString;
548
+ lifecycle: z.ZodOptional<z.ZodObject<{
549
+ groupId: z.ZodString;
550
+ phase: z.ZodEnum<{
551
+ observe: "observe";
552
+ start: "start";
553
+ }>;
554
+ intervalMs: z.ZodOptional<z.ZodNumber>;
555
+ }, z.core.$strict>>;
325
556
  payloadSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
326
557
  requiresExplicitId: z.ZodOptional<z.ZodBoolean>;
327
558
  sampling: z.ZodOptional<z.ZodObject<{
@@ -359,6 +590,14 @@ export declare const SdkConfigurationSchema: z.ZodObject<{
359
590
  reliable: "reliable";
360
591
  }>;
361
592
  id: z.ZodString;
593
+ lifecycle: z.ZodOptional<z.ZodObject<{
594
+ groupId: z.ZodString;
595
+ phase: z.ZodEnum<{
596
+ observe: "observe";
597
+ start: "start";
598
+ }>;
599
+ intervalMs: z.ZodOptional<z.ZodNumber>;
600
+ }, z.core.$strict>>;
362
601
  payloadSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
363
602
  requiresExplicitId: z.ZodOptional<z.ZodBoolean>;
364
603
  sampling: z.ZodOptional<z.ZodObject<{
@@ -394,7 +633,9 @@ export declare const LivePulseTargetSchema: z.ZodObject<{
394
633
  memberId: z.ZodString;
395
634
  organizationId: z.ZodString;
396
635
  reducer: z.ZodEnum<{
636
+ "additive-window": "additive-window";
397
637
  "distinct-per-window": "distinct-per-window";
638
+ lifecycle: "lifecycle";
398
639
  presence: "presence";
399
640
  }>;
400
641
  shardId: z.ZodString;
@@ -446,7 +687,9 @@ export declare const RoutedLivePulseSchema: z.ZodObject<{
446
687
  memberId: z.ZodString;
447
688
  organizationId: z.ZodString;
448
689
  reducer: z.ZodEnum<{
690
+ "additive-window": "additive-window";
449
691
  "distinct-per-window": "distinct-per-window";
692
+ lifecycle: "lifecycle";
450
693
  presence: "presence";
451
694
  }>;
452
695
  shardId: z.ZodString;
@@ -542,3 +785,8 @@ export type LiveShardSnapshot = z.infer<typeof LiveShardSnapshotSchema>;
542
785
  export type MetricPulseTarget = z.infer<typeof MetricPulseTargetSchema>;
543
786
  export type MetricSnapshot = z.infer<typeof MetricSnapshotSchema>;
544
787
  export type RoutedMetricPulse = z.infer<typeof RoutedMetricPulseSchema>;
788
+ export type RoutedAdditivePulse = z.infer<typeof RoutedAdditivePulseSchema>;
789
+ export type AdditivePulseTarget = z.infer<typeof AdditivePulseTargetSchema>;
790
+ export type RoutedLifecyclePulse = z.infer<typeof RoutedLifecyclePulseSchema>;
791
+ export type LifecyclePulseTarget = z.infer<typeof LifecyclePulseTargetSchema>;
792
+ 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
@@ -11,22 +11,29 @@ var compilePayloadSchema = (value) => {
11
11
  const required = new Set(schema.required);
12
12
  const fields = {};
13
13
  for (const [name, field] of Object.entries(schema.properties)) {
14
- let validator;
15
- if (field.enum) {
16
- if (field.enum.length === 0 || field.enum.some((item) => typeof item !== "string")) {
17
- throw new Error(`Invalid enum for ${name}.`);
18
- }
19
- validator = z.enum(field.enum);
20
- } else if (field.type === "string") {
14
+ let validator2;
15
+ if (field.pattern !== void 0 && field.type !== "string") {
16
+ throw new Error(`Pattern is only supported for string field ${name}.`);
17
+ }
18
+ if (field.type === "string" || field.type === void 0 && field.enum?.every((item) => typeof item === "string")) {
21
19
  let string = z.string();
22
20
  if (field.minLength !== void 0)
23
21
  string = string.min(field.minLength);
24
22
  if (field.maxLength !== void 0)
25
23
  string = string.max(field.maxLength);
24
+ if (field.pattern !== void 0) {
25
+ let pattern;
26
+ try {
27
+ pattern = new RegExp(field.pattern);
28
+ } catch {
29
+ throw new Error(`Invalid pattern for ${name}.`);
30
+ }
31
+ string = string.regex(pattern);
32
+ }
26
33
  if (field.format === "date-time") {
27
- validator = string.refine((item) => Number.isFinite(Date.parse(item)), "Invalid date-time.");
34
+ validator2 = string.refine((item) => Number.isFinite(Date.parse(item)), "Invalid date-time.");
28
35
  } else if (field.format === void 0) {
29
- validator = string;
36
+ validator2 = string;
30
37
  } else {
31
38
  throw new Error(`Unsupported string format for ${name}.`);
32
39
  }
@@ -36,20 +43,46 @@ var compilePayloadSchema = (value) => {
36
43
  number = number.min(field.minimum);
37
44
  if (field.maximum !== void 0)
38
45
  number = number.max(field.maximum);
39
- validator = number;
46
+ validator2 = number;
40
47
  } else if (field.type === "boolean") {
41
- validator = z.boolean();
48
+ validator2 = z.boolean();
42
49
  } else {
43
50
  throw new Error(`Unsupported payload field ${name}.`);
44
51
  }
45
- fields[name] = required.has(name) ? validator : validator.optional();
52
+ if (field.enum !== void 0) {
53
+ const enumType = field.type === "number" || field.type === "integer" ? "number" : "string";
54
+ if (field.enum.length === 0 || field.enum.some((item) => typeof item !== enumType)) {
55
+ throw new Error(`Invalid enum for ${name}.`);
56
+ }
57
+ const allowed = new Set(field.enum);
58
+ validator2 = validator2.refine((item) => allowed.has(item), "Invalid enum value.");
59
+ }
60
+ fields[name] = required.has(name) ? validator2 : validator2.optional();
46
61
  }
47
62
  for (const name of required) {
48
63
  if (!fields[name])
49
64
  throw new Error(`Required payload field ${name} is undefined.`);
50
65
  }
51
66
  const object = z.object(fields);
52
- return schema.additionalProperties === false ? object.strict() : object.passthrough();
67
+ const validator = schema.additionalProperties === false ? object.strict() : object.passthrough();
68
+ if (!schema.dependentRequired)
69
+ return validator;
70
+ for (const [field, dependents] of Object.entries(schema.dependentRequired)) {
71
+ if (!fields[field] || !Array.isArray(dependents) || dependents.some((dependent) => !fields[dependent])) {
72
+ throw new Error(`Invalid dependentRequired for ${field}.`);
73
+ }
74
+ }
75
+ return validator.superRefine((payload, context) => {
76
+ for (const [field, dependents] of Object.entries(schema.dependentRequired ?? {})) {
77
+ if (payload[field] === void 0)
78
+ continue;
79
+ for (const dependent of dependents) {
80
+ if (payload[dependent] === void 0) {
81
+ context.addIssue({ code: "custom", path: [dependent], message: `${dependent} is required with ${field}.` });
82
+ }
83
+ }
84
+ }
85
+ });
53
86
  };
54
87
 
55
88
  // ../../libs/contracts/dist/index.js
@@ -178,6 +211,11 @@ var PulseDeliverySchema = z2.enum(["live", "reliable"]);
178
211
  var PulseTypeDefinitionSchema = z2.object({
179
212
  delivery: PulseDeliverySchema,
180
213
  id: z2.string().trim().min(1).max(100),
214
+ lifecycle: z2.object({
215
+ groupId: z2.string().trim().min(1).max(100),
216
+ phase: z2.enum(["start", "observe"]),
217
+ intervalMs: z2.number().int().min(1e3).max(864e5).optional()
218
+ }).strict().optional(),
181
219
  payloadSchema: z2.record(z2.string(), z2.unknown()),
182
220
  requiresExplicitId: z2.boolean().optional(),
183
221
  sampling: z2.object({
@@ -196,15 +234,53 @@ var PulseTypeDefinitionSchema = z2.object({
196
234
  if (definition.sampling?.unit === "pulse" && definition.sampling.keyField !== null) {
197
235
  context.addIssue({ code: "custom", path: ["sampling", "keyField"], message: "Pulse sampling must not define keyField." });
198
236
  }
237
+ if (definition.lifecycle && (definition.samplingAllowed || !definition.requiresExplicitId || definition.delivery !== "live" || definition.lifecycle.phase === "observe" !== Boolean(definition.lifecycle.intervalMs))) {
238
+ context.addIssue({ code: "custom", path: ["lifecycle"], message: "Lifecycle pulses need explicit IDs, live delivery, and an interval only for observations." });
239
+ }
240
+ });
241
+ var SignalReducerSchema = z2.enum(["presence", "distinct-per-window", "lifecycle", "additive-window"]);
242
+ var MetricNameSchema = z2.string().regex(/^[A-Za-z][A-Za-z0-9_]*$/).max(64);
243
+ var AdditiveMetricRuleSchema = z2.object({
244
+ name: MetricNameSchema,
245
+ op: z2.enum(["count", "sum"]),
246
+ unitField: z2.string().trim().min(1).max(200).optional(),
247
+ valueField: z2.string().trim().min(1).max(200).optional(),
248
+ when: z2.object({
249
+ field: z2.string().trim().min(1).max(200),
250
+ equals: z2.union([z2.string(), z2.number().finite(), z2.boolean()])
251
+ }).strict().optional()
252
+ }).strict().superRefine((rule, context) => {
253
+ if (rule.op === "sum" !== Boolean(rule.valueField)) {
254
+ context.addIssue({ code: "custom", path: ["valueField"], message: "Sum needs valueField; count must not have one." });
255
+ }
199
256
  });
200
- var SignalReducerSchema = z2.enum(["presence", "distinct-per-window"]);
201
257
  var SignalTypeDefinitionSchema = z2.object({
258
+ additive: z2.object({
259
+ baseScopeFields: z2.array(z2.string().trim().min(1).max(100)).max(4).optional(),
260
+ cohortAtField: z2.string().trim().min(1).max(200),
261
+ dedupeFields: z2.array(z2.string().trim().min(1).max(200)).min(1).max(4),
262
+ dimensionSets: z2.array(z2.object({
263
+ kind: z2.string().trim().min(1).max(100),
264
+ fields: z2.array(z2.string().trim().min(1).max(100)).max(4)
265
+ }).strict()).min(1).max(16),
266
+ latenessMs: z2.number().int().min(0).max(36e5),
267
+ metricRules: z2.array(AdditiveMetricRuleSchema).min(1).max(32),
268
+ primaryMetric: MetricNameSchema,
269
+ stableScopeField: z2.string().trim().min(1).max(200).optional()
270
+ }).strict().optional(),
202
271
  id: z2.string().trim().min(1).max(100),
203
272
  inputPulseTypes: z2.array(z2.object({
204
273
  id: z2.string().trim().min(1).max(100),
205
274
  version: z2.number().int().positive()
206
275
  }).strict()).min(1),
207
276
  dimensions: z2.array(z2.string().trim().min(1).max(100)).optional(),
277
+ lifecycle: z2.object({
278
+ intervalMs: z2.number().int().min(1e3).max(864e5),
279
+ scopeFields: z2.array(z2.string().trim().min(1).max(100)).min(1),
280
+ targetSampledPerScopeShard: z2.number().int().positive(),
281
+ timeoutMs: z2.number().int().min(1e3).max(864e5),
282
+ windowMs: z2.number().int().min(1e3).max(864e5)
283
+ }).strict().optional(),
208
284
  partitionKey: z2.string().trim().min(1).max(200).optional(),
209
285
  presence: z2.object({
210
286
  immutableDimensions: z2.array(z2.string().trim().min(1).max(100)).optional(),
@@ -228,16 +304,38 @@ var SignalTypeDefinitionSchema = z2.object({
228
304
  if (definition.reducer === "distinct-per-window" && !definition.windowMs) {
229
305
  context.addIssue({ code: "custom", path: ["windowMs"], message: "Windowed distinct needs a window." });
230
306
  }
307
+ if (definition.reducer === "lifecycle" && (!definition.lifecycle || !definition.partitionKey || !definition.dimensions?.length)) {
308
+ context.addIssue({ code: "custom", path: ["lifecycle"], message: "Lifecycle needs a configuration, partition key and dimensions." });
309
+ }
310
+ if (definition.reducer === "additive-window") {
311
+ const additive = definition.additive;
312
+ if (!additive || !definition.windowMs || !definition.partitionKey) {
313
+ context.addIssue({ code: "custom", path: ["additive"], message: "Additive window needs metrics, windowMs and partitionKey." });
314
+ } else {
315
+ const names = additive.metricRules.map((rule) => rule.name);
316
+ if (new Set(names).size !== names.length || !names.includes(additive.primaryMetric)) {
317
+ context.addIssue({ code: "custom", path: ["additive", "metricRules"], message: "Metric names must be unique and include the primary metric." });
318
+ }
319
+ const kinds = additive.dimensionSets.map((set) => set.kind);
320
+ const sets = additive.dimensionSets.map((set) => JSON.stringify(set.fields));
321
+ if (new Set(sets).size !== sets.length || new Set(kinds).size !== kinds.length) {
322
+ context.addIssue({ code: "custom", path: ["additive", "dimensionSets"], message: "Dimension sets and kinds must be unique." });
323
+ }
324
+ }
325
+ } else if (definition.additive) {
326
+ context.addIssue({ code: "custom", path: ["additive"], message: "Only additive-window signals may define additive metrics." });
327
+ }
231
328
  const schedule = definition.shardSchedule ?? [];
232
- if (schedule.length > 0 && (!definition.partitionKey || !definition.dimensions?.length)) {
329
+ if (schedule.length > 0 && (!definition.partitionKey || !definition.dimensions?.length && !definition.additive?.dimensionSets.length)) {
233
330
  context.addIssue({ code: "custom", path: ["partitionKey"], message: "Scheduled routing needs partitionKey and dimensions." });
234
331
  }
235
332
  if (schedule.length > 0 && definition.reducer === "presence" && !definition.presence?.startedAtField) {
236
333
  context.addIssue({ code: "custom", path: ["presence", "startedAtField"], message: "Scheduled presence needs a stable start time." });
237
334
  }
238
- if (definition.windowMs) {
335
+ const scheduledWindowMs = definition.windowMs ?? definition.lifecycle?.windowMs;
336
+ if (scheduledWindowMs) {
239
337
  for (let index = 0; index < schedule.length; index += 1) {
240
- if (Date.parse(schedule[index].effectiveFrom) % definition.windowMs !== 0) {
338
+ if (Date.parse(schedule[index].effectiveFrom) % scheduledWindowMs !== 0) {
241
339
  context.addIssue({ code: "custom", path: ["shardSchedule", index], message: "Window generation must start on a window boundary." });
242
340
  }
243
341
  }
@@ -250,7 +348,8 @@ var SignalTypeDefinitionSchema = z2.object({
250
348
  });
251
349
  var MetricSignalRefSchema = z2.object({
252
350
  signalId: z2.string().trim().min(1).max(500),
253
- dimension: z2.string().trim().min(1).max(100)
351
+ dimension: z2.string().trim().min(1).max(100),
352
+ scope: z2.record(z2.string().trim().min(1).max(100), z2.string().trim().min(1).max(200)).optional()
254
353
  }).strict();
255
354
  var MetricPulseTargetSchema = z2.object({
256
355
  generation: z2.number().int().positive(),
@@ -282,25 +381,99 @@ var RoutedMetricPulseSchema = z2.object({
282
381
  pulse: RawPulseSchema,
283
382
  target: MetricPulseTargetSchema
284
383
  }).strict();
384
+ var AdditivePulseTargetSchema = z2.object({
385
+ dedupeKey: z2.string().trim().min(1).max(500),
386
+ generation: z2.number().int().positive(),
387
+ latenessMs: z2.number().int().min(0).max(36e5),
388
+ metrics: z2.record(MetricNameSchema, z2.number().int().safe().nonnegative()),
389
+ organizationId: z2.string().trim().min(1).max(100),
390
+ ownerId: z2.string().trim().min(1).max(500),
391
+ primaryMetric: MetricNameSchema,
392
+ refs: z2.array(MetricSignalRefSchema).min(1).max(16),
393
+ shardCount: z2.number().int().min(1).max(65536),
394
+ shardIndex: z2.number().int().nonnegative(),
395
+ signalTypeId: z2.string().trim().min(1).max(100),
396
+ stableScopeKey: z2.string().trim().min(1).max(500).optional(),
397
+ windowEndAt: isoDateTime,
398
+ windowStartAt: isoDateTime
399
+ }).strict().superRefine((target, context) => {
400
+ if (target.shardIndex >= target.shardCount || Date.parse(target.windowStartAt) >= Date.parse(target.windowEndAt)) {
401
+ context.addIssue({ code: "custom", path: ["shardIndex"], message: "Invalid additive shard or window." });
402
+ }
403
+ if (!Object.hasOwn(target.metrics, target.primaryMetric)) {
404
+ context.addIssue({ code: "custom", path: ["metrics"], message: "Additive metrics must include the primary metric." });
405
+ }
406
+ if (new Set(target.refs.map((ref) => ref.signalId)).size !== target.refs.length) {
407
+ context.addIssue({ code: "custom", path: ["refs"], message: "Additive signal references must be unique." });
408
+ }
409
+ });
410
+ var RoutedAdditivePulseSchema = z2.object({ pulse: RawPulseSchema, target: AdditivePulseTargetSchema }).strict();
411
+ var LifecyclePulseTargetSchema = z2.object({
412
+ generation: z2.number().int().positive(),
413
+ groupId: z2.string().trim().min(1).max(100),
414
+ intervalMs: z2.number().int().positive(),
415
+ organizationId: z2.string().trim().min(1).max(100),
416
+ ownerId: z2.string().trim().min(1).max(500),
417
+ phase: z2.enum(["start", "observe"]),
418
+ refs: z2.array(MetricSignalRefSchema).min(1),
419
+ scopeId: z2.string().trim().min(1).max(500),
420
+ sessionStartedAt: isoDateTime,
421
+ shardCount: z2.number().int().min(1).max(65536),
422
+ shardIndex: z2.number().int().nonnegative(),
423
+ targetSampledPerScopeShard: z2.number().int().positive(),
424
+ timeoutMs: z2.number().int().positive(),
425
+ windowMs: z2.number().int().positive()
426
+ }).strict().superRefine((target, context) => {
427
+ if (target.shardIndex >= target.shardCount) {
428
+ context.addIssue({ code: "custom", path: ["shardIndex"], message: "Shard index exceeds shard count." });
429
+ }
430
+ });
431
+ var RoutedLifecyclePulseSchema = z2.object({
432
+ pulse: RawPulseSchema,
433
+ target: LifecyclePulseTargetSchema
434
+ }).strict();
435
+ var LifecycleDecisionSchema = z2.object({
436
+ accepted: z2.boolean(),
437
+ nextHeartbeatAtMs: z2.number().int().nonnegative(),
438
+ probability: z2.number().positive().max(1),
439
+ selected: z2.boolean()
440
+ }).strict();
285
441
  var MetricSnapshotSchema = z2.object({
286
442
  estimatedCount: z2.number().finite().nonnegative(),
443
+ varianceEstimate: z2.number().finite().nonnegative().optional(),
287
444
  generation: z2.number().int().positive(),
288
- kind: z2.enum(["presence", "distinct-per-window"]),
445
+ kind: z2.enum(["presence", "distinct-per-window", "additive-window"]),
446
+ latenessMs: z2.number().int().min(0).max(36e5).optional(),
447
+ metrics: z2.record(MetricNameSchema, z2.number().int().safe().nonnegative()).optional(),
289
448
  observedAt: isoDateTime,
290
449
  organizationId: z2.string().trim().min(1).max(100),
291
450
  originId: z2.string().trim().min(1).max(200),
292
451
  recovering: z2.boolean(),
293
452
  revision: z2.number().int().nonnegative(),
294
453
  sampledCount: z2.number().int().nonnegative(),
454
+ scope: z2.record(z2.string().trim().min(1).max(100), z2.string().trim().min(1).max(200)).optional(),
455
+ scopeKind: z2.string().trim().min(1).max(100).optional(),
295
456
  shardCount: z2.number().int().min(1).max(65536),
296
457
  signalId: z2.string().trim().min(1).max(500),
297
458
  signalTypeId: z2.string().trim().min(1).max(100),
298
459
  windowEndAt: isoDateTime.optional(),
299
460
  windowStartAt: isoDateTime.optional()
300
461
  }).strict().superRefine((snapshot, context) => {
301
- if (snapshot.kind === "distinct-per-window" && (!snapshot.windowStartAt || !snapshot.windowEndAt)) {
462
+ if ((snapshot.kind === "distinct-per-window" || snapshot.kind === "additive-window") && (!snapshot.windowStartAt || !snapshot.windowEndAt)) {
302
463
  context.addIssue({ code: "custom", path: ["windowStartAt"], message: "Distinct snapshot needs a window." });
303
464
  }
465
+ if (snapshot.kind === "additive-window" && !snapshot.metrics) {
466
+ context.addIssue({ code: "custom", path: ["metrics"], message: "Additive snapshot needs metrics." });
467
+ }
468
+ if (snapshot.kind === "additive-window" && snapshot.latenessMs === void 0) {
469
+ context.addIssue({ code: "custom", path: ["latenessMs"], message: "Additive snapshot needs its lateness bound." });
470
+ }
471
+ if (snapshot.kind === "additive-window" && (!snapshot.scope || !snapshot.scopeKind)) {
472
+ context.addIssue({ code: "custom", path: ["scope"], message: "Additive snapshot needs a structured scope." });
473
+ }
474
+ if (snapshot.kind !== "additive-window" && snapshot.metrics) {
475
+ context.addIssue({ code: "custom", path: ["metrics"], message: "Only additive snapshots carry metrics." });
476
+ }
304
477
  if (snapshot.kind === "presence" && (snapshot.windowStartAt || snapshot.windowEndAt)) {
305
478
  context.addIssue({ code: "custom", path: ["windowStartAt"], message: "Presence snapshot must not have a window." });
306
479
  }
@@ -435,6 +608,47 @@ var CloudflareSdkConfigurationCache = class {
435
608
  }
436
609
  };
437
610
 
611
+ // src/sessionDecisionCache.ts
612
+ var MemorySessionDecisionCache = class {
613
+ constructor(maxEntries = 1e4) {
614
+ this.maxEntries = maxEntries;
615
+ }
616
+ maxEntries;
617
+ values = /* @__PURE__ */ new Map();
618
+ async get(key) {
619
+ return this.values.get(key);
620
+ }
621
+ async set(key, decision) {
622
+ this.values.delete(key);
623
+ this.values.set(key, decision);
624
+ if (this.values.size > this.maxEntries) this.values.delete(this.values.keys().next().value);
625
+ }
626
+ };
627
+ var CloudflareSessionDecisionCache = class {
628
+ constructor(cache, namespace = "https://session-decision-cache.ultd.invalid") {
629
+ this.cache = cache;
630
+ this.namespace = namespace;
631
+ }
632
+ cache;
633
+ namespace;
634
+ request(key) {
635
+ return new Request(`${this.namespace}/${key}`);
636
+ }
637
+ async get(key) {
638
+ const response = await this.cache.match(this.request(key));
639
+ if (!response) return void 0;
640
+ const value = await response.json();
641
+ 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;
642
+ return value;
643
+ }
644
+ async set(key, decision) {
645
+ const seconds = Math.max(1, Math.floor((decision.expiresAtMs - Date.now()) / 1e3));
646
+ await this.cache.put(this.request(key), new Response(JSON.stringify(decision), {
647
+ headers: { "cache-control": `public, max-age=${seconds}`, "content-type": "application/json" }
648
+ }));
649
+ }
650
+ };
651
+
438
652
  // src/cache.ts
439
653
  var MemorySamplingPolicyCache = class {
440
654
  policies = /* @__PURE__ */ new Map();
@@ -499,6 +713,8 @@ var UnlimitedClient = class {
499
713
  baseUrl;
500
714
  source;
501
715
  cache;
716
+ sessionCache;
717
+ sessionMemory = new MemorySessionDecisionCache();
502
718
  fetcher;
503
719
  now;
504
720
  randomUUID;
@@ -516,6 +732,7 @@ var UnlimitedClient = class {
516
732
  this.baseUrl = options.baseUrl.replace(/\/$/, "");
517
733
  this.source = options.source;
518
734
  this.cache = options.cache ?? new MemorySdkConfigurationCache();
735
+ this.sessionCache = options.sessionCache ?? this.sessionMemory;
519
736
  this.fetcher = options.fetch ?? fetch;
520
737
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
521
738
  this.randomUUID = options.randomUUID ?? (() => crypto.randomUUID());
@@ -584,6 +801,28 @@ var UnlimitedClient = class {
584
801
  });
585
802
  return this.refreshing;
586
803
  }
804
+ async decisionKey(groupId, organizationId, sessionId) {
805
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(
806
+ `${await this.cacheKey}\0${groupId}\0${organizationId}\0${sessionId}`
807
+ ));
808
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
809
+ }
810
+ async getDecision(key) {
811
+ const memory = await this.sessionMemory.get(key);
812
+ if (memory && memory.expiresAtMs > this.now().getTime()) return memory;
813
+ const cached = await this.sessionCache.get(key).catch(() => void 0);
814
+ if (cached && cached.expiresAtMs > this.now().getTime()) {
815
+ await this.sessionMemory.set(key, cached);
816
+ return cached;
817
+ }
818
+ return void 0;
819
+ }
820
+ async putDecision(key, decision) {
821
+ await this.sessionMemory.set(key, decision);
822
+ if (this.sessionCache !== this.sessionMemory) {
823
+ await this.sessionCache.set(key, decision).catch(() => void 0);
824
+ }
825
+ }
587
826
  async emit(input) {
588
827
  const loaded = await this.configuration();
589
828
  const type = loaded.types.get(typeKey(input.pulse.type, input.pulse.version));
@@ -602,9 +841,36 @@ var UnlimitedClient = class {
602
841
  if (!type.entry.enabled) {
603
842
  return { accepted: false, disabled: true, pulseId: id, sampledOut: false };
604
843
  }
844
+ const lifecycle = type.entry.definition.lifecycle;
845
+ const decisionKey = lifecycle ? await this.decisionKey(lifecycle.groupId, input.organizationId, id) : void 0;
846
+ if (lifecycle?.phase === "observe" && decisionKey) {
847
+ const decision = await this.getDecision(decisionKey);
848
+ if (!decision) {
849
+ const start = [...loaded.types.values()].find((candidate) => candidate.entry.definition.lifecycle?.groupId === lifecycle.groupId && candidate.entry.definition.lifecycle.phase === "start");
850
+ if (!start) throw new Error(`Lifecycle group ${lifecycle.groupId} has no start PulseType.`);
851
+ return this.emit({
852
+ ...input,
853
+ pulse: { ...input.pulse, type: start.entry.definition.id, version: start.entry.definition.version }
854
+ });
855
+ }
856
+ if (decision && !decision.selected) {
857
+ return { accepted: false, pulseId: id, sampledOut: true, selected: false, probability: decision.probability };
858
+ }
859
+ if (decision && this.now().getTime() < decision.nextHeartbeatAtMs && decision.lastVideoId === payload.videoId) {
860
+ return {
861
+ accepted: false,
862
+ pulseId: id,
863
+ sampledOut: false,
864
+ throttled: true,
865
+ selected: true,
866
+ probability: decision.probability,
867
+ nextHeartbeatAtMs: decision.nextHeartbeatAtMs
868
+ };
869
+ }
870
+ }
605
871
  const policy = type.entry.samplingPolicy;
606
872
  let sampling = NoSamplingContext;
607
- if (policy) {
873
+ if (policy && !lifecycle) {
608
874
  sampling = {
609
875
  applied: policy.keepProbability < 1,
610
876
  appliedKeepProbability: policy.keepProbability,
@@ -639,6 +905,26 @@ var UnlimitedClient = class {
639
905
  });
640
906
  if (!response.ok) throw new Error(`Pulse request failed with ${response.status}.`);
641
907
  const body = await response.json();
908
+ if (lifecycle && decisionKey) {
909
+ if (typeof body.selected !== "boolean" || typeof body.probability !== "number" || body.probability <= 0 || body.probability > 1 || typeof body.nextHeartbeatAtMs !== "number") {
910
+ throw new Error("Lifecycle response did not contain a valid session decision.");
911
+ }
912
+ await this.putDecision(decisionKey, {
913
+ selected: body.selected,
914
+ probability: body.probability,
915
+ nextHeartbeatAtMs: body.nextHeartbeatAtMs,
916
+ lastVideoId: body.selected && typeof payload.videoId === "string" ? payload.videoId : void 0,
917
+ expiresAtMs: this.now().getTime() + 24 * 60 * 60 * 1e3
918
+ });
919
+ return {
920
+ accepted: body.accepted !== false,
921
+ pulseId: id,
922
+ sampledOut: lifecycle.phase === "observe" && !body.selected,
923
+ selected: body.selected,
924
+ probability: body.probability,
925
+ nextHeartbeatAtMs: body.nextHeartbeatAtMs
926
+ };
927
+ }
642
928
  return {
643
929
  accepted: body.accepted !== false,
644
930
  ...body.disabled === true ? { disabled: true } : {},
@@ -650,7 +936,9 @@ var UnlimitedClient = class {
650
936
  export {
651
937
  CloudflareSamplingPolicyCache,
652
938
  CloudflareSdkConfigurationCache,
939
+ CloudflareSessionDecisionCache,
653
940
  MemorySamplingPolicyCache,
654
941
  MemorySdkConfigurationCache,
942
+ MemorySessionDecisionCache,
655
943
  UnlimitedClient
656
944
  };
@@ -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.3.0",
4
4
  "type": "module",
5
5
  "files": ["dist", "README.md"],
6
6
  "publishConfig": { "access": "public" },
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "scripts": {
16
16
  "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json --emitDeclarationOnly && node scripts/bundle.mjs",
17
- "prepack": "pnpm --dir ../../libs/contracts run build && pnpm run build",
17
+ "prepack": "corepack pnpm --dir ../../libs/contracts run build && corepack pnpm run build",
18
18
  "test": "vitest run test --exclude 'dist/**'",
19
19
  "typecheck": "tsc -p tsconfig.json --noEmit"
20
20
  },