@ultdai/pulse 0.3.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>;
@@ -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.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
@@ -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;
@@ -801,6 +818,13 @@ var UnlimitedClient = class {
801
818
  });
802
819
  return this.refreshing;
803
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
+ }
804
828
  async decisionKey(groupId, organizationId, sessionId) {
805
829
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(
806
830
  `${await this.cacheKey}\0${groupId}\0${organizationId}\0${sessionId}`
@@ -842,6 +866,8 @@ var UnlimitedClient = class {
842
866
  return { accepted: false, disabled: true, pulseId: id, sampledOut: false };
843
867
  }
844
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;
845
871
  const decisionKey = lifecycle ? await this.decisionKey(lifecycle.groupId, input.organizationId, id) : void 0;
846
872
  if (lifecycle?.phase === "observe" && decisionKey) {
847
873
  const decision = await this.getDecision(decisionKey);
@@ -856,7 +882,7 @@ var UnlimitedClient = class {
856
882
  if (decision && !decision.selected) {
857
883
  return { accepted: false, pulseId: id, sampledOut: true, selected: false, probability: decision.probability };
858
884
  }
859
- if (decision && this.now().getTime() < decision.nextHeartbeatAtMs && decision.lastVideoId === payload.videoId) {
885
+ if (decision && this.now().getTime() < decision.nextHeartbeatAtMs && decision.lastReportedChangeKey === changeKey) {
860
886
  return {
861
887
  accepted: false,
862
888
  pulseId: id,
@@ -913,7 +939,7 @@ var UnlimitedClient = class {
913
939
  selected: body.selected,
914
940
  probability: body.probability,
915
941
  nextHeartbeatAtMs: body.nextHeartbeatAtMs,
916
- lastVideoId: body.selected && typeof payload.videoId === "string" ? payload.videoId : void 0,
942
+ ...body.selected && changeKey ? { lastReportedChangeKey: changeKey } : {},
917
943
  expiresAtMs: this.now().getTime() + 24 * 60 * 60 * 1e3
918
944
  });
919
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.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "files": ["dist", "README.md"],
6
6
  "publishConfig": { "access": "public" },