@ultdai/pulse 0.2.0 → 0.3.1

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,49 +7,54 @@ npm install @ultdai/pulse
7
7
  ```
8
8
 
9
9
  ```ts
10
- import {
11
- CloudflareSdkConfigurationCache,
12
- CloudflareSessionDecisionCache,
13
- UnlimitedClient,
14
- } from '@ultdai/pulse'
10
+ import { CloudflareSdkConfigurationCache, UnlimitedClient } from '@ultdai/pulse'
15
11
 
16
- export interface Env { ULTD_API_TOKEN: string }
12
+ interface Env {
13
+ ULTD_API_TOKEN: string
14
+ ULTD_API_URL: string
15
+ ULTD_ORGANIZATION_ID: string
16
+ }
17
17
 
18
18
  let client: UnlimitedClient | undefined
19
19
 
20
20
  export default {
21
21
  async fetch(request: Request, env: Env, context: ExecutionContext) {
22
- const session = getPlaybackSession(request) // stabilní ID a čas vzniku v Unixových milisekundách
23
22
  client ??= new UnlimitedClient({
24
23
  apiToken: env.ULTD_API_TOKEN,
25
- baseUrl: 'https://api.ultd.tivio.studio',
24
+ baseUrl: env.ULTD_API_URL,
26
25
  cache: new CloudflareSdkConfigurationCache(caches.default),
27
- sessionCache: new CloudflareSessionDecisionCache(caches.default),
28
- source: 'stream-worker',
26
+ source: 'my-server',
29
27
  })
30
- context.waitUntil(client.emit({
31
- occurredAtMs: Date.now(),
32
- organizationId: 'tivio',
33
- pulse: {
34
- type: 'playback.session.playing',
35
- version: 1,
36
- id: session.id,
37
- payload: {
38
- sessionStartedAtMs: session.startedAtMs,
39
- videoId: session.videoId,
40
- ...(session.channelId ? { channelId: session.channelId } : {}),
28
+
29
+ // Inicializace načte aktuální povolené typy a jejich pravidla. Další
30
+ // volání používají platnou cache; po expiraci se katalog obnoví.
31
+ context.waitUntil((async () => {
32
+ await client.initialize('') // výběr organizace je rezervovaný pro budoucí katalog
33
+ await client.emit({
34
+ organizationId: env.ULTD_ORGANIZATION_ID,
35
+ occurredAtMs: Date.now(),
36
+ pulse: {
37
+ type: 'your.configured.pulse-type',
38
+ version: 1,
39
+ id: crypto.randomUUID(),
40
+ payload: { value: 1 },
41
41
  },
42
- },
43
- }).catch((error) => console.error('ULTD pulse failed', error)))
42
+ })
43
+ })().catch((error) => console.error('ULTD pulse failed', error)))
44
+
44
45
  return new Response('ok')
45
46
  },
46
47
  }
47
48
  ```
48
49
 
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.
50
+ Ukázkový PulseType výše je jen zástupný název. Skutečný název, verzi, identitu a pole payloadu určuje vaše konfigurace v ULTD. Opakované pulsy, které patří ke stejné sledované entitě, musí používat stabilní `pulse.id`; jednorázová událost potřebuje ID stabilní při opakovaném odeslání.
51
+
52
+ `apiToken` 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.
53
+
54
+ `await client.initialize(token)` načte povolené PulseTypes, jejich schémata a pravidla měření z autorizovaného ULTD API nebo z dosud platné sdílené cache. Argument `token` je rezervovaný pro budoucí výběr organizace a jejího katalogu; v tomto POC se nepoužívá a smí být `''`. Není to API klíč ani náhrada za `apiToken`. Samotný konstruktor nemůže čekat na síť. Pokud inicializaci vynecháte, `emit()` konfiguraci načte před prvním pulsem. Neznámý typ nebo nevalidní `pulse.payload` SDK odmítne lokálně. Vypnutý nebo nevzorkovaný puls neodešle. 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.
50
55
 
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.
56
+ Pro opakované pulsy může konfigurace definovat lifecycle s úvodním a průběžným PulseTypem. SDK při prvním volání odešle úvodní puls, uloží rozhodnutí o dalším měření a dodržuje interval z konfigurace. `CloudflareSessionDecisionCache(caches.default)` lze předat jako `sessionCache`, aby se toto rozhodnutí sdílelo mezi instancemi Workeru; bez něj se používá paměť instance. Cache je pouze optimalizace a její ztráta nezakládá novou logickou entitu.
52
57
 
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.
58
+ SDK obsahuje obecné 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á. SDK neobsahuje zákaznické PulseTypes. Samotné zveřejnění SDK neposkytuje oprávnění k API.
54
59
 
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.
60
+ Dnešní POC má na backendu jeden technický API klíč a jeden katalog definic. Rozdělení katalogu podle organizace je další serverový krok; rozhraní SDK už s jejím tokenem při inicializaci počítá.
@@ -163,6 +163,7 @@ export declare const PulseTypeDefinitionSchema: z.ZodObject<{
163
163
  start: "start";
164
164
  }>;
165
165
  intervalMs: z.ZodOptional<z.ZodNumber>;
166
+ reportOnChangeFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
166
167
  }, z.core.$strict>>;
167
168
  payloadSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
168
169
  requiresExplicitId: z.ZodOptional<z.ZodBoolean>;
@@ -177,11 +178,37 @@ export declare const PulseTypeDefinitionSchema: z.ZodObject<{
177
178
  version: z.ZodNumber;
178
179
  }, z.core.$strict>;
179
180
  export declare const SignalReducerSchema: z.ZodEnum<{
181
+ "additive-window": "additive-window";
180
182
  "distinct-per-window": "distinct-per-window";
181
183
  lifecycle: "lifecycle";
182
184
  presence: "presence";
183
185
  }>;
184
186
  export declare const SignalTypeDefinitionSchema: z.ZodObject<{
187
+ additive: z.ZodOptional<z.ZodObject<{
188
+ baseScopeFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
189
+ cohortAtField: z.ZodString;
190
+ dedupeFields: z.ZodArray<z.ZodString>;
191
+ dimensionSets: z.ZodArray<z.ZodObject<{
192
+ kind: z.ZodString;
193
+ fields: z.ZodArray<z.ZodString>;
194
+ }, z.core.$strict>>;
195
+ latenessMs: z.ZodNumber;
196
+ metricRules: z.ZodArray<z.ZodObject<{
197
+ name: z.ZodString;
198
+ op: z.ZodEnum<{
199
+ count: "count";
200
+ sum: "sum";
201
+ }>;
202
+ unitField: z.ZodOptional<z.ZodString>;
203
+ valueField: z.ZodOptional<z.ZodString>;
204
+ when: z.ZodOptional<z.ZodObject<{
205
+ field: z.ZodString;
206
+ equals: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
207
+ }, z.core.$strict>>;
208
+ }, z.core.$strict>>;
209
+ primaryMetric: z.ZodString;
210
+ stableScopeField: z.ZodOptional<z.ZodString>;
211
+ }, z.core.$strict>>;
185
212
  id: z.ZodString;
186
213
  inputPulseTypes: z.ZodArray<z.ZodObject<{
187
214
  id: z.ZodString;
@@ -203,6 +230,7 @@ export declare const SignalTypeDefinitionSchema: z.ZodObject<{
203
230
  timeoutMs: z.ZodNumber;
204
231
  }, z.core.$strict>>;
205
232
  reducer: z.ZodEnum<{
233
+ "additive-window": "additive-window";
206
234
  "distinct-per-window": "distinct-per-window";
207
235
  lifecycle: "lifecycle";
208
236
  presence: "presence";
@@ -229,6 +257,7 @@ export declare const MetricPulseTargetSchema: z.ZodObject<{
229
257
  refs: z.ZodArray<z.ZodObject<{
230
258
  signalId: z.ZodString;
231
259
  dimension: z.ZodString;
260
+ scope: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
232
261
  }, z.core.$strict>>;
233
262
  sessionStartedAt: z.ZodString;
234
263
  shardCount: z.ZodNumber;
@@ -289,6 +318,7 @@ export declare const RoutedMetricPulseSchema: z.ZodObject<{
289
318
  refs: z.ZodArray<z.ZodObject<{
290
319
  signalId: z.ZodString;
291
320
  dimension: z.ZodString;
321
+ scope: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
292
322
  }, z.core.$strict>>;
293
323
  sessionStartedAt: z.ZodString;
294
324
  shardCount: z.ZodNumber;
@@ -299,6 +329,86 @@ export declare const RoutedMetricPulseSchema: z.ZodObject<{
299
329
  windowStartAt: z.ZodOptional<z.ZodString>;
300
330
  }, z.core.$strict>;
301
331
  }, z.core.$strict>;
332
+ /** A pulse carries a bounded vector of exact, non-sampled metric additions. */
333
+ export declare const AdditivePulseTargetSchema: z.ZodObject<{
334
+ dedupeKey: z.ZodString;
335
+ generation: z.ZodNumber;
336
+ latenessMs: z.ZodNumber;
337
+ metrics: z.ZodRecord<z.ZodString, z.ZodNumber>;
338
+ organizationId: z.ZodString;
339
+ ownerId: z.ZodString;
340
+ primaryMetric: z.ZodString;
341
+ refs: z.ZodArray<z.ZodObject<{
342
+ signalId: z.ZodString;
343
+ dimension: z.ZodString;
344
+ scope: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
345
+ }, z.core.$strict>>;
346
+ shardCount: z.ZodNumber;
347
+ shardIndex: z.ZodNumber;
348
+ signalTypeId: z.ZodString;
349
+ stableScopeKey: z.ZodOptional<z.ZodString>;
350
+ windowEndAt: z.ZodString;
351
+ windowStartAt: z.ZodString;
352
+ }, z.core.$strict>;
353
+ export declare const RoutedAdditivePulseSchema: z.ZodObject<{
354
+ pulse: z.ZodObject<{
355
+ data: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
356
+ eventId: z.ZodOptional<z.ZodString>;
357
+ id: z.ZodString;
358
+ organizationId: z.ZodString;
359
+ sampling: z.ZodDefault<z.ZodObject<{
360
+ applied: z.ZodBoolean;
361
+ appliedKeepProbability: z.ZodNumber;
362
+ effectiveKeepProbability: z.ZodNumber;
363
+ ingressApplied: z.ZodBoolean;
364
+ ingressKeepProbability: z.ZodNumber;
365
+ ingressMethod: z.ZodEnum<{
366
+ none: "none";
367
+ "pulse-hash-v1": "pulse-hash-v1";
368
+ }>;
369
+ knownKeepProbability: z.ZodNullable<z.ZodNumber>;
370
+ method: z.ZodEnum<{
371
+ none: "none";
372
+ "pulse-hash-v1": "pulse-hash-v1";
373
+ }>;
374
+ policyId: z.ZodNullable<z.ZodString>;
375
+ policyRevision: z.ZodNullable<z.ZodNumber>;
376
+ unit: z.ZodEnum<{
377
+ member: "member";
378
+ pulse: "pulse";
379
+ }>;
380
+ }, z.core.$strict>>;
381
+ source: z.ZodString;
382
+ subject: z.ZodOptional<z.ZodObject<{
383
+ id: z.ZodString;
384
+ type: z.ZodString;
385
+ }, z.core.$strip>>;
386
+ type: z.ZodString;
387
+ typeVersion: z.ZodNumber;
388
+ occurredAt: z.ZodString;
389
+ receivedAt: z.ZodString;
390
+ }, z.core.$strict>;
391
+ target: z.ZodObject<{
392
+ dedupeKey: z.ZodString;
393
+ generation: z.ZodNumber;
394
+ latenessMs: z.ZodNumber;
395
+ metrics: z.ZodRecord<z.ZodString, z.ZodNumber>;
396
+ organizationId: z.ZodString;
397
+ ownerId: z.ZodString;
398
+ primaryMetric: z.ZodString;
399
+ refs: z.ZodArray<z.ZodObject<{
400
+ signalId: z.ZodString;
401
+ dimension: z.ZodString;
402
+ scope: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
403
+ }, z.core.$strict>>;
404
+ shardCount: z.ZodNumber;
405
+ shardIndex: z.ZodNumber;
406
+ signalTypeId: z.ZodString;
407
+ stableScopeKey: z.ZodOptional<z.ZodString>;
408
+ windowEndAt: z.ZodString;
409
+ windowStartAt: z.ZodString;
410
+ }, z.core.$strict>;
411
+ }, z.core.$strict>;
302
412
  export declare const LifecyclePulseTargetSchema: z.ZodObject<{
303
413
  generation: z.ZodNumber;
304
414
  groupId: z.ZodString;
@@ -312,6 +422,7 @@ export declare const LifecyclePulseTargetSchema: z.ZodObject<{
312
422
  refs: z.ZodArray<z.ZodObject<{
313
423
  signalId: z.ZodString;
314
424
  dimension: z.ZodString;
425
+ scope: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
315
426
  }, z.core.$strict>>;
316
427
  scopeId: z.ZodString;
317
428
  sessionStartedAt: z.ZodString;
@@ -372,6 +483,7 @@ export declare const RoutedLifecyclePulseSchema: z.ZodObject<{
372
483
  refs: z.ZodArray<z.ZodObject<{
373
484
  signalId: z.ZodString;
374
485
  dimension: z.ZodString;
486
+ scope: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
375
487
  }, z.core.$strict>>;
376
488
  scopeId: z.ZodString;
377
489
  sessionStartedAt: z.ZodString;
@@ -393,15 +505,20 @@ export declare const MetricSnapshotSchema: z.ZodObject<{
393
505
  varianceEstimate: z.ZodOptional<z.ZodNumber>;
394
506
  generation: z.ZodNumber;
395
507
  kind: z.ZodEnum<{
508
+ "additive-window": "additive-window";
396
509
  "distinct-per-window": "distinct-per-window";
397
510
  presence: "presence";
398
511
  }>;
512
+ latenessMs: z.ZodOptional<z.ZodNumber>;
513
+ metrics: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
399
514
  observedAt: z.ZodString;
400
515
  organizationId: z.ZodString;
401
516
  originId: z.ZodString;
402
517
  recovering: z.ZodBoolean;
403
518
  revision: z.ZodNumber;
404
519
  sampledCount: z.ZodNumber;
520
+ scope: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
521
+ scopeKind: z.ZodOptional<z.ZodString>;
405
522
  shardCount: z.ZodNumber;
406
523
  signalId: z.ZodString;
407
524
  signalTypeId: z.ZodString;
@@ -436,6 +553,7 @@ export declare const SdkPulseTypeSchema: z.ZodObject<{
436
553
  start: "start";
437
554
  }>;
438
555
  intervalMs: z.ZodOptional<z.ZodNumber>;
556
+ reportOnChangeFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
439
557
  }, z.core.$strict>>;
440
558
  payloadSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
441
559
  requiresExplicitId: z.ZodOptional<z.ZodBoolean>;
@@ -481,6 +599,7 @@ export declare const SdkConfigurationSchema: z.ZodObject<{
481
599
  start: "start";
482
600
  }>;
483
601
  intervalMs: z.ZodOptional<z.ZodNumber>;
602
+ reportOnChangeFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
484
603
  }, z.core.$strict>>;
485
604
  payloadSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
486
605
  requiresExplicitId: z.ZodOptional<z.ZodBoolean>;
@@ -517,6 +636,7 @@ export declare const LivePulseTargetSchema: z.ZodObject<{
517
636
  memberId: z.ZodString;
518
637
  organizationId: z.ZodString;
519
638
  reducer: z.ZodEnum<{
639
+ "additive-window": "additive-window";
520
640
  "distinct-per-window": "distinct-per-window";
521
641
  lifecycle: "lifecycle";
522
642
  presence: "presence";
@@ -570,6 +690,7 @@ export declare const RoutedLivePulseSchema: z.ZodObject<{
570
690
  memberId: z.ZodString;
571
691
  organizationId: z.ZodString;
572
692
  reducer: z.ZodEnum<{
693
+ "additive-window": "additive-window";
573
694
  "distinct-per-window": "distinct-per-window";
574
695
  lifecycle: "lifecycle";
575
696
  presence: "presence";
@@ -667,6 +788,8 @@ export type LiveShardSnapshot = z.infer<typeof LiveShardSnapshotSchema>;
667
788
  export type MetricPulseTarget = z.infer<typeof MetricPulseTargetSchema>;
668
789
  export type MetricSnapshot = z.infer<typeof MetricSnapshotSchema>;
669
790
  export type RoutedMetricPulse = z.infer<typeof RoutedMetricPulseSchema>;
791
+ export type RoutedAdditivePulse = z.infer<typeof RoutedAdditivePulseSchema>;
792
+ export type AdditivePulseTarget = z.infer<typeof AdditivePulseTargetSchema>;
670
793
  export type RoutedLifecyclePulse = z.infer<typeof RoutedLifecyclePulseSchema>;
671
794
  export type LifecyclePulseTarget = z.infer<typeof LifecyclePulseTargetSchema>;
672
795
  export type LifecycleDecision = z.infer<typeof LifecycleDecisionSchema>;
package/dist/index.d.ts CHANGED
@@ -49,6 +49,11 @@ export declare class UnlimitedClient {
49
49
  private install;
50
50
  private refresh;
51
51
  private configuration;
52
+ /** Load the authorized, versioned PulseType catalog before producing pulses.
53
+ * The organization token is reserved for future catalog scoping and currently
54
+ * has no effect, including when it is an empty string. API authentication
55
+ * continues to use the apiToken supplied in the constructor. */
56
+ initialize(_organizationToken: string): Promise<void>;
52
57
  private decisionKey;
53
58
  private getDecision;
54
59
  private putDecision;
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
@@ -181,7 +214,8 @@ var PulseTypeDefinitionSchema = z2.object({
181
214
  lifecycle: z2.object({
182
215
  groupId: z2.string().trim().min(1).max(100),
183
216
  phase: z2.enum(["start", "observe"]),
184
- intervalMs: z2.number().int().min(1e3).max(864e5).optional()
217
+ intervalMs: z2.number().int().min(1e3).max(864e5).optional(),
218
+ reportOnChangeFields: z2.array(z2.string().trim().min(1).max(200)).max(8).optional()
185
219
  }).strict().optional(),
186
220
  payloadSchema: z2.record(z2.string(), z2.unknown()),
187
221
  requiresExplicitId: z2.boolean().optional(),
@@ -204,9 +238,47 @@ var PulseTypeDefinitionSchema = z2.object({
204
238
  if (definition.lifecycle && (definition.samplingAllowed || !definition.requiresExplicitId || definition.delivery !== "live" || definition.lifecycle.phase === "observe" !== Boolean(definition.lifecycle.intervalMs))) {
205
239
  context.addIssue({ code: "custom", path: ["lifecycle"], message: "Lifecycle pulses need explicit IDs, live delivery, and an interval only for observations." });
206
240
  }
241
+ if (definition.lifecycle?.reportOnChangeFields) {
242
+ const fields = definition.lifecycle.reportOnChangeFields;
243
+ if (definition.lifecycle.phase !== "observe" || new Set(fields).size !== fields.length) {
244
+ context.addIssue({
245
+ code: "custom",
246
+ path: ["lifecycle", "reportOnChangeFields"],
247
+ message: "Change-trigger fields must be unique and belong to an observation PulseType."
248
+ });
249
+ }
250
+ }
251
+ });
252
+ var SignalReducerSchema = z2.enum(["presence", "distinct-per-window", "lifecycle", "additive-window"]);
253
+ var MetricNameSchema = z2.string().regex(/^[A-Za-z][A-Za-z0-9_]*$/).max(64);
254
+ var AdditiveMetricRuleSchema = z2.object({
255
+ name: MetricNameSchema,
256
+ op: z2.enum(["count", "sum"]),
257
+ unitField: z2.string().trim().min(1).max(200).optional(),
258
+ valueField: z2.string().trim().min(1).max(200).optional(),
259
+ when: z2.object({
260
+ field: z2.string().trim().min(1).max(200),
261
+ equals: z2.union([z2.string(), z2.number().finite(), z2.boolean()])
262
+ }).strict().optional()
263
+ }).strict().superRefine((rule, context) => {
264
+ if (rule.op === "sum" !== Boolean(rule.valueField)) {
265
+ context.addIssue({ code: "custom", path: ["valueField"], message: "Sum needs valueField; count must not have one." });
266
+ }
207
267
  });
208
- var SignalReducerSchema = z2.enum(["presence", "distinct-per-window", "lifecycle"]);
209
268
  var SignalTypeDefinitionSchema = z2.object({
269
+ additive: z2.object({
270
+ baseScopeFields: z2.array(z2.string().trim().min(1).max(100)).max(4).optional(),
271
+ cohortAtField: z2.string().trim().min(1).max(200),
272
+ dedupeFields: z2.array(z2.string().trim().min(1).max(200)).min(1).max(4),
273
+ dimensionSets: z2.array(z2.object({
274
+ kind: z2.string().trim().min(1).max(100),
275
+ fields: z2.array(z2.string().trim().min(1).max(100)).max(4)
276
+ }).strict()).min(1).max(16),
277
+ latenessMs: z2.number().int().min(0).max(36e5),
278
+ metricRules: z2.array(AdditiveMetricRuleSchema).min(1).max(32),
279
+ primaryMetric: MetricNameSchema,
280
+ stableScopeField: z2.string().trim().min(1).max(200).optional()
281
+ }).strict().optional(),
210
282
  id: z2.string().trim().min(1).max(100),
211
283
  inputPulseTypes: z2.array(z2.object({
212
284
  id: z2.string().trim().min(1).max(100),
@@ -246,8 +318,26 @@ var SignalTypeDefinitionSchema = z2.object({
246
318
  if (definition.reducer === "lifecycle" && (!definition.lifecycle || !definition.partitionKey || !definition.dimensions?.length)) {
247
319
  context.addIssue({ code: "custom", path: ["lifecycle"], message: "Lifecycle needs a configuration, partition key and dimensions." });
248
320
  }
321
+ if (definition.reducer === "additive-window") {
322
+ const additive = definition.additive;
323
+ if (!additive || !definition.windowMs || !definition.partitionKey) {
324
+ context.addIssue({ code: "custom", path: ["additive"], message: "Additive window needs metrics, windowMs and partitionKey." });
325
+ } else {
326
+ const names = additive.metricRules.map((rule) => rule.name);
327
+ if (new Set(names).size !== names.length || !names.includes(additive.primaryMetric)) {
328
+ context.addIssue({ code: "custom", path: ["additive", "metricRules"], message: "Metric names must be unique and include the primary metric." });
329
+ }
330
+ const kinds = additive.dimensionSets.map((set) => set.kind);
331
+ const sets = additive.dimensionSets.map((set) => JSON.stringify(set.fields));
332
+ if (new Set(sets).size !== sets.length || new Set(kinds).size !== kinds.length) {
333
+ context.addIssue({ code: "custom", path: ["additive", "dimensionSets"], message: "Dimension sets and kinds must be unique." });
334
+ }
335
+ }
336
+ } else if (definition.additive) {
337
+ context.addIssue({ code: "custom", path: ["additive"], message: "Only additive-window signals may define additive metrics." });
338
+ }
249
339
  const schedule = definition.shardSchedule ?? [];
250
- if (schedule.length > 0 && (!definition.partitionKey || !definition.dimensions?.length)) {
340
+ if (schedule.length > 0 && (!definition.partitionKey || !definition.dimensions?.length && !definition.additive?.dimensionSets.length)) {
251
341
  context.addIssue({ code: "custom", path: ["partitionKey"], message: "Scheduled routing needs partitionKey and dimensions." });
252
342
  }
253
343
  if (schedule.length > 0 && definition.reducer === "presence" && !definition.presence?.startedAtField) {
@@ -269,7 +359,8 @@ var SignalTypeDefinitionSchema = z2.object({
269
359
  });
270
360
  var MetricSignalRefSchema = z2.object({
271
361
  signalId: z2.string().trim().min(1).max(500),
272
- dimension: z2.string().trim().min(1).max(100)
362
+ dimension: z2.string().trim().min(1).max(100),
363
+ scope: z2.record(z2.string().trim().min(1).max(100), z2.string().trim().min(1).max(200)).optional()
273
364
  }).strict();
274
365
  var MetricPulseTargetSchema = z2.object({
275
366
  generation: z2.number().int().positive(),
@@ -301,6 +392,33 @@ var RoutedMetricPulseSchema = z2.object({
301
392
  pulse: RawPulseSchema,
302
393
  target: MetricPulseTargetSchema
303
394
  }).strict();
395
+ var AdditivePulseTargetSchema = z2.object({
396
+ dedupeKey: z2.string().trim().min(1).max(500),
397
+ generation: z2.number().int().positive(),
398
+ latenessMs: z2.number().int().min(0).max(36e5),
399
+ metrics: z2.record(MetricNameSchema, z2.number().int().safe().nonnegative()),
400
+ organizationId: z2.string().trim().min(1).max(100),
401
+ ownerId: z2.string().trim().min(1).max(500),
402
+ primaryMetric: MetricNameSchema,
403
+ refs: z2.array(MetricSignalRefSchema).min(1).max(16),
404
+ shardCount: z2.number().int().min(1).max(65536),
405
+ shardIndex: z2.number().int().nonnegative(),
406
+ signalTypeId: z2.string().trim().min(1).max(100),
407
+ stableScopeKey: z2.string().trim().min(1).max(500).optional(),
408
+ windowEndAt: isoDateTime,
409
+ windowStartAt: isoDateTime
410
+ }).strict().superRefine((target, context) => {
411
+ if (target.shardIndex >= target.shardCount || Date.parse(target.windowStartAt) >= Date.parse(target.windowEndAt)) {
412
+ context.addIssue({ code: "custom", path: ["shardIndex"], message: "Invalid additive shard or window." });
413
+ }
414
+ if (!Object.hasOwn(target.metrics, target.primaryMetric)) {
415
+ context.addIssue({ code: "custom", path: ["metrics"], message: "Additive metrics must include the primary metric." });
416
+ }
417
+ if (new Set(target.refs.map((ref) => ref.signalId)).size !== target.refs.length) {
418
+ context.addIssue({ code: "custom", path: ["refs"], message: "Additive signal references must be unique." });
419
+ }
420
+ });
421
+ var RoutedAdditivePulseSchema = z2.object({ pulse: RawPulseSchema, target: AdditivePulseTargetSchema }).strict();
304
422
  var LifecyclePulseTargetSchema = z2.object({
305
423
  generation: z2.number().int().positive(),
306
424
  groupId: z2.string().trim().min(1).max(100),
@@ -335,22 +453,38 @@ var MetricSnapshotSchema = z2.object({
335
453
  estimatedCount: z2.number().finite().nonnegative(),
336
454
  varianceEstimate: z2.number().finite().nonnegative().optional(),
337
455
  generation: z2.number().int().positive(),
338
- kind: z2.enum(["presence", "distinct-per-window"]),
456
+ kind: z2.enum(["presence", "distinct-per-window", "additive-window"]),
457
+ latenessMs: z2.number().int().min(0).max(36e5).optional(),
458
+ metrics: z2.record(MetricNameSchema, z2.number().int().safe().nonnegative()).optional(),
339
459
  observedAt: isoDateTime,
340
460
  organizationId: z2.string().trim().min(1).max(100),
341
461
  originId: z2.string().trim().min(1).max(200),
342
462
  recovering: z2.boolean(),
343
463
  revision: z2.number().int().nonnegative(),
344
464
  sampledCount: z2.number().int().nonnegative(),
465
+ scope: z2.record(z2.string().trim().min(1).max(100), z2.string().trim().min(1).max(200)).optional(),
466
+ scopeKind: z2.string().trim().min(1).max(100).optional(),
345
467
  shardCount: z2.number().int().min(1).max(65536),
346
468
  signalId: z2.string().trim().min(1).max(500),
347
469
  signalTypeId: z2.string().trim().min(1).max(100),
348
470
  windowEndAt: isoDateTime.optional(),
349
471
  windowStartAt: isoDateTime.optional()
350
472
  }).strict().superRefine((snapshot, context) => {
351
- if (snapshot.kind === "distinct-per-window" && (!snapshot.windowStartAt || !snapshot.windowEndAt)) {
473
+ if ((snapshot.kind === "distinct-per-window" || snapshot.kind === "additive-window") && (!snapshot.windowStartAt || !snapshot.windowEndAt)) {
352
474
  context.addIssue({ code: "custom", path: ["windowStartAt"], message: "Distinct snapshot needs a window." });
353
475
  }
476
+ if (snapshot.kind === "additive-window" && !snapshot.metrics) {
477
+ context.addIssue({ code: "custom", path: ["metrics"], message: "Additive snapshot needs metrics." });
478
+ }
479
+ if (snapshot.kind === "additive-window" && snapshot.latenessMs === void 0) {
480
+ context.addIssue({ code: "custom", path: ["latenessMs"], message: "Additive snapshot needs its lateness bound." });
481
+ }
482
+ if (snapshot.kind === "additive-window" && (!snapshot.scope || !snapshot.scopeKind)) {
483
+ context.addIssue({ code: "custom", path: ["scope"], message: "Additive snapshot needs a structured scope." });
484
+ }
485
+ if (snapshot.kind !== "additive-window" && snapshot.metrics) {
486
+ context.addIssue({ code: "custom", path: ["metrics"], message: "Only additive snapshots carry metrics." });
487
+ }
354
488
  if (snapshot.kind === "presence" && (snapshot.windowStartAt || snapshot.windowEndAt)) {
355
489
  context.addIssue({ code: "custom", path: ["windowStartAt"], message: "Presence snapshot must not have a window." });
356
490
  }
@@ -585,6 +719,12 @@ var configKey = async (baseUrl, apiToken) => {
585
719
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${baseUrl}\0${apiToken}`));
586
720
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
587
721
  };
722
+ var reportedChangeKey = async (fields, payload) => {
723
+ if (!fields?.length) return void 0;
724
+ const values = fields.map((field) => [field, Object.hasOwn(payload, field), payload[field]]);
725
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(values)));
726
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
727
+ };
588
728
  var UnlimitedClient = class {
589
729
  apiToken;
590
730
  baseUrl;
@@ -678,6 +818,13 @@ var UnlimitedClient = class {
678
818
  });
679
819
  return this.refreshing;
680
820
  }
821
+ /** Load the authorized, versioned PulseType catalog before producing pulses.
822
+ * The organization token is reserved for future catalog scoping and currently
823
+ * has no effect, including when it is an empty string. API authentication
824
+ * continues to use the apiToken supplied in the constructor. */
825
+ async initialize(_organizationToken) {
826
+ await this.configuration();
827
+ }
681
828
  async decisionKey(groupId, organizationId, sessionId) {
682
829
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(
683
830
  `${await this.cacheKey}\0${groupId}\0${organizationId}\0${sessionId}`
@@ -719,6 +866,8 @@ var UnlimitedClient = class {
719
866
  return { accepted: false, disabled: true, pulseId: id, sampledOut: false };
720
867
  }
721
868
  const lifecycle = type.entry.definition.lifecycle;
869
+ const observation = lifecycle?.phase === "start" ? [...loaded.types.values()].find((candidate) => candidate.entry.definition.lifecycle?.groupId === lifecycle.groupId && candidate.entry.definition.lifecycle.phase === "observe") : type;
870
+ const changeKey = lifecycle ? await reportedChangeKey(observation?.entry.definition.lifecycle?.reportOnChangeFields, payload) : void 0;
722
871
  const decisionKey = lifecycle ? await this.decisionKey(lifecycle.groupId, input.organizationId, id) : void 0;
723
872
  if (lifecycle?.phase === "observe" && decisionKey) {
724
873
  const decision = await this.getDecision(decisionKey);
@@ -733,7 +882,7 @@ var UnlimitedClient = class {
733
882
  if (decision && !decision.selected) {
734
883
  return { accepted: false, pulseId: id, sampledOut: true, selected: false, probability: decision.probability };
735
884
  }
736
- if (decision && this.now().getTime() < decision.nextHeartbeatAtMs && decision.lastVideoId === payload.videoId) {
885
+ if (decision && this.now().getTime() < decision.nextHeartbeatAtMs && decision.lastReportedChangeKey === changeKey) {
737
886
  return {
738
887
  accepted: false,
739
888
  pulseId: id,
@@ -790,7 +939,7 @@ var UnlimitedClient = class {
790
939
  selected: body.selected,
791
940
  probability: body.probability,
792
941
  nextHeartbeatAtMs: body.nextHeartbeatAtMs,
793
- lastVideoId: body.selected && typeof payload.videoId === "string" ? payload.videoId : void 0,
942
+ ...body.selected && changeKey ? { lastReportedChangeKey: changeKey } : {},
794
943
  expiresAtMs: this.now().getTime() + 24 * 60 * 60 * 1e3
795
944
  });
796
945
  return {
@@ -1,7 +1,7 @@
1
1
  import type { CacheApiLike } from './cache.js';
2
2
  export interface SessionDecision {
3
3
  expiresAtMs: number;
4
- lastVideoId?: string;
4
+ lastReportedChangeKey?: string;
5
5
  nextHeartbeatAtMs: number;
6
6
  probability: number;
7
7
  selected: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultdai/pulse",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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
  },