@ultdai/pulse 0.3.0 → 0.4.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,49 +7,51 @@ 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
+ context.waitUntil((async () => {
30
+ await client.emit({
31
+ organizationId: env.ULTD_ORGANIZATION_ID,
32
+ occurredAtMs: Date.now(),
33
+ pulse: {
34
+ type: 'your.configured.pulse-type',
35
+ version: 1,
36
+ id: crypto.randomUUID(),
37
+ payload: { value: 1 },
41
38
  },
42
- },
43
- }).catch((error) => console.error('ULTD pulse failed', error)))
39
+ })
40
+ })().catch((error) => console.error('ULTD pulse failed', error)))
41
+
44
42
  return new Response('ok')
45
43
  },
46
44
  }
47
45
  ```
48
46
 
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.
47
+ 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í.
48
+
49
+ `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.
50
+
51
+ Konstruktor uloží nastavení bez síťového volání. První `emit()` načte povolené PulseTypes, jejich schémata a pravidla měření z autorizovaného ULTD API nebo z dosud platné sdílené cache, a teprve potom puls lokálně validuje. Neznámý typ nebo nevalidní `pulse.payload` SDK odmítne před POSTem. Vypnutý nebo nevzorkovaný puls neodešle. Další volání používají platnou konfiguraci; po expiraci se obnoví. Pokud obnova selže, puls se neodešle. Cloudflare cache je volitelná; bez ní SDK používá paměť instance.
50
52
 
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.
53
+ 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
54
 
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.
55
+ 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
56
 
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.
57
+ Dnešní POC má na backendu jeden technický API klíč a jeden katalog definic. Budoucí výběr katalogu podle organizace bude vycházet z identity API klíče na serveru; volající nebude předávat další token ani měnit veřejné rozhraní SDK.
@@ -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>;
@@ -552,6 +553,7 @@ export declare const SdkPulseTypeSchema: z.ZodObject<{
552
553
  start: "start";
553
554
  }>;
554
555
  intervalMs: z.ZodOptional<z.ZodNumber>;
556
+ reportOnChangeFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
555
557
  }, z.core.$strict>>;
556
558
  payloadSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
557
559
  requiresExplicitId: z.ZodOptional<z.ZodBoolean>;
@@ -597,6 +599,7 @@ export declare const SdkConfigurationSchema: z.ZodObject<{
597
599
  start: "start";
598
600
  }>;
599
601
  intervalMs: z.ZodOptional<z.ZodNumber>;
602
+ reportOnChangeFields: z.ZodOptional<z.ZodArray<z.ZodString>>;
600
603
  }, z.core.$strict>>;
601
604
  payloadSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
602
605
  requiresExplicitId: z.ZodOptional<z.ZodBoolean>;
package/dist/index.js CHANGED
@@ -214,7 +214,8 @@ var PulseTypeDefinitionSchema = z2.object({
214
214
  lifecycle: z2.object({
215
215
  groupId: z2.string().trim().min(1).max(100),
216
216
  phase: z2.enum(["start", "observe"]),
217
- 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()
218
219
  }).strict().optional(),
219
220
  payloadSchema: z2.record(z2.string(), z2.unknown()),
220
221
  requiresExplicitId: z2.boolean().optional(),
@@ -237,6 +238,16 @@ var PulseTypeDefinitionSchema = z2.object({
237
238
  if (definition.lifecycle && (definition.samplingAllowed || !definition.requiresExplicitId || definition.delivery !== "live" || definition.lifecycle.phase === "observe" !== Boolean(definition.lifecycle.intervalMs))) {
238
239
  context.addIssue({ code: "custom", path: ["lifecycle"], message: "Lifecycle pulses need explicit IDs, live delivery, and an interval only for observations." });
239
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
+ }
240
251
  });
241
252
  var SignalReducerSchema = z2.enum(["presence", "distinct-per-window", "lifecycle", "additive-window"]);
242
253
  var MetricNameSchema = z2.string().regex(/^[A-Za-z][A-Za-z0-9_]*$/).max(64);
@@ -708,6 +719,12 @@ var configKey = async (baseUrl, apiToken) => {
708
719
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${baseUrl}\0${apiToken}`));
709
720
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
710
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
+ };
711
728
  var UnlimitedClient = class {
712
729
  apiToken;
713
730
  baseUrl;
@@ -842,6 +859,8 @@ var UnlimitedClient = class {
842
859
  return { accepted: false, disabled: true, pulseId: id, sampledOut: false };
843
860
  }
844
861
  const lifecycle = type.entry.definition.lifecycle;
862
+ const observation = lifecycle?.phase === "start" ? [...loaded.types.values()].find((candidate) => candidate.entry.definition.lifecycle?.groupId === lifecycle.groupId && candidate.entry.definition.lifecycle.phase === "observe") : type;
863
+ const changeKey = lifecycle ? await reportedChangeKey(observation?.entry.definition.lifecycle?.reportOnChangeFields, payload) : void 0;
845
864
  const decisionKey = lifecycle ? await this.decisionKey(lifecycle.groupId, input.organizationId, id) : void 0;
846
865
  if (lifecycle?.phase === "observe" && decisionKey) {
847
866
  const decision = await this.getDecision(decisionKey);
@@ -856,7 +875,7 @@ var UnlimitedClient = class {
856
875
  if (decision && !decision.selected) {
857
876
  return { accepted: false, pulseId: id, sampledOut: true, selected: false, probability: decision.probability };
858
877
  }
859
- if (decision && this.now().getTime() < decision.nextHeartbeatAtMs && decision.lastVideoId === payload.videoId) {
878
+ if (decision && this.now().getTime() < decision.nextHeartbeatAtMs && decision.lastReportedChangeKey === changeKey) {
860
879
  return {
861
880
  accepted: false,
862
881
  pulseId: id,
@@ -913,7 +932,7 @@ var UnlimitedClient = class {
913
932
  selected: body.selected,
914
933
  probability: body.probability,
915
934
  nextHeartbeatAtMs: body.nextHeartbeatAtMs,
916
- lastVideoId: body.selected && typeof payload.videoId === "string" ? payload.videoId : void 0,
935
+ ...body.selected && changeKey ? { lastReportedChangeKey: changeKey } : {},
917
936
  expiresAtMs: this.now().getTime() + 24 * 60 * 60 * 1e3
918
937
  });
919
938
  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.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "files": ["dist", "README.md"],
6
6
  "publishConfig": { "access": "public" },