@oxy-hq/sdk 2.5.0 → 2.8.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/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // @oxy/sdk - TypeScript SDK for Oxy data platform
2
- import { _ as interpretCustomerAppError, a as useFunction, c as useQuery, d as useTrackEvent, f as _resetCustomerAppManifestCacheForTest, g as apiErrorFromResponse, h as OxyApiError, i as useAgentRun, l as useResolvedManifest, m as readInjectedAppConfig, n as OxyAppProvider, o as useOxyApp, p as loadCustomerAppManifest, r as OxyChat, s as useProcedureRun, t as OxyAnswer, u as useSemanticQuery, v as getOxyAppLogger, y as setOxyAppLogger } from "./react-CVdWXcu8.mjs";
2
+ import { _ as interpretCustomAppError, a as useFunction, c as useQuery, d as useTrackEvent, f as _resetCustomAppManifestCacheForTest, g as apiErrorFromResponse, h as OxyApiError, i as useAgentRun, l as useResolvedManifest, m as readInjectedAppConfig, n as OxyAppProvider, o as useOxyApp, p as loadCustomAppManifest, r as OxyChat, s as useProcedureRun, t as OxyAnswer, u as useSemanticQuery, v as getOxyAppLogger, y as setOxyAppLogger } from "./react-sT7E4CbA.mjs";
3
+ import * as React from "react";
3
4
 
4
5
  //#region src/anomalies.ts
5
6
  /**
@@ -49,11 +50,20 @@ var AnomaliesClient = class {
49
50
  * workspace, runs the detector, and upserts matching rows into the
50
51
  * inbox. Returns counts of scanned / failed / persisted.
51
52
  *
53
+ * Long-running: the server waits up to 55 s, then returns
54
+ * `pending: true` with zeroed counts while the scan finishes in the
55
+ * background. Always check `pending` before treating `0` as "nothing
56
+ * found", and refetch with {@link list} shortly after.
57
+ *
52
58
  * @example
53
59
  * ```typescript
54
60
  * // Scan against a known-good reference date (matches the seed dataset)
55
61
  * const result = await client.anomalies.scan({ as_of: "2025-12-15" });
56
- * console.log(`${result.anomalies_persisted} anomalies detected`);
62
+ * if (result.pending) {
63
+ * console.log("scan still running — refetch shortly");
64
+ * } else {
65
+ * console.log(`${result.anomalies_persisted} anomalies detected`);
66
+ * }
57
67
  * ```
58
68
  */
59
69
  async scan(options = {}) {
@@ -73,23 +83,119 @@ var AnomaliesClient = class {
73
83
  }
74
84
  /**
75
85
  * Run the metric-tree `explain` for an anomaly and cache the result on
76
- * the row. Subsequent calls return the cached `ExplainResult` instantly.
86
+ * the row. Subsequent calls return the cached `ExplainResult` instantly;
87
+ * pass `{ refresh: true }` to bust the cache and recompute.
88
+ *
89
+ * The uncached path runs a 20-30 s recursive driver search — budget for it
90
+ * (or read `explain_cache` off the row from {@link list} when it's already
91
+ * populated).
77
92
  */
78
- async explain(anomalyId) {
79
- const query = this.buildQuery();
80
- return this.request(this.path(`/${encodeURIComponent(anomalyId)}/explain${query}`), { method: "POST" });
93
+ async explain(anomalyId, options = {}) {
94
+ const extra = {};
95
+ if (options.refresh) extra.refresh = "true";
96
+ return this.request(this.path(`/${encodeURIComponent(anomalyId)}/explain${this.buildQuery(extra)}`), { method: "POST" });
81
97
  }
82
98
  };
83
99
 
84
100
  //#endregion
85
- //#region src/customer-app/debug.ts
101
+ //#region src/custom-app/base64.ts
102
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
103
+ /** Reverse lookup; 255 marks "not a base64 character". */
104
+ const B64R = /* @__PURE__ */ (() => {
105
+ const t = (/* @__PURE__ */ new Uint8Array(256)).fill(255);
106
+ for (let i = 0; i < 64; i++) t[B64.charCodeAt(i)] = i;
107
+ return t;
108
+ })();
109
+ /**
110
+ * Chunk size for building output in segments. Byte-at-a-time `+=` allocates a
111
+ * rope node per byte, and `String.fromCharCode.apply` blows the argument limit
112
+ * on large inputs; 8k avoids both.
113
+ */
114
+ const CHUNK = 8192;
115
+ function asBytes(input) {
116
+ if (input instanceof Uint8Array) return input;
117
+ if (input instanceof ArrayBuffer) return new Uint8Array(input);
118
+ return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
119
+ }
120
+ /**
121
+ * Encode bytes as standard (padded) base64.
122
+ *
123
+ * ```ts
124
+ * const pdf = new Uint8Array(await renderReport());
125
+ * await ctx.email.send({
126
+ * to: ctx.user.email,
127
+ * subject: "Report",
128
+ * text: "attached",
129
+ * attachments: [{ filename: "report.pdf", content: bytesToBase64(pdf) }]
130
+ * });
131
+ * ```
132
+ *
133
+ * For **text** you generated, skip this entirely and pass the string with
134
+ * `encoding: "utf8"` — it needs no encoder and stays byte-exact for non-ASCII.
135
+ */
136
+ function bytesToBase64(input) {
137
+ const bytes = asBytes(input);
138
+ const parts = [];
139
+ let buf = "";
140
+ for (let i = 0; i < bytes.length; i += 3) {
141
+ const b0 = bytes[i];
142
+ const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
143
+ const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
144
+ const n = b0 << 16 | b1 << 8 | b2;
145
+ buf += B64[n >> 18 & 63] + B64[n >> 12 & 63] + (i + 1 < bytes.length ? B64[n >> 6 & 63] : "=") + (i + 2 < bytes.length ? B64[n & 63] : "=");
146
+ if (buf.length >= CHUNK) {
147
+ parts.push(buf);
148
+ buf = "";
149
+ }
150
+ }
151
+ parts.push(buf);
152
+ return parts.join("");
153
+ }
154
+ /**
155
+ * Decode standard base64 to bytes — e.g. the body from
156
+ * `ctx.storage.get(key, { encoding: "base64" })`.
157
+ *
158
+ * Throws on malformed input rather than returning a short buffer: a truncated
159
+ * decode that reports success is a corrupt file nobody notices.
160
+ */
161
+ function base64ToBytes(base64) {
162
+ let s = String(base64).replace(/[ \t\n\f\r]/g, "");
163
+ if (s.length % 4 === 0) {
164
+ let pad = 0;
165
+ while (pad < 2 && s.charCodeAt(s.length - 1) === 61) {
166
+ s = s.slice(0, -1);
167
+ pad++;
168
+ }
169
+ }
170
+ if (s.indexOf("=") >= 0) throw new TypeError("base64ToBytes: '=' may only appear as trailing padding");
171
+ if (s.length % 4 === 1) throw new TypeError("base64ToBytes: invalid base64 length");
172
+ const out = new Uint8Array(s.length * 3 >> 2);
173
+ let o = 0;
174
+ let buf = 0;
175
+ let bits = 0;
176
+ for (let i = 0; i < s.length; i++) {
177
+ const code = s.charCodeAt(i);
178
+ const v = code < 256 ? B64R[code] : 255;
179
+ if (v === 255) throw new TypeError(`base64ToBytes: invalid base64 character '${s[i]}'`);
180
+ buf = buf << 6 | v;
181
+ bits += 6;
182
+ if (bits >= 8) {
183
+ bits -= 8;
184
+ out[o++] = buf >> bits & 255;
185
+ }
186
+ }
187
+ return out.subarray(0, o);
188
+ }
189
+
190
+ //#endregion
191
+ //#region src/custom-app/debug.ts
86
192
  /**
87
193
  * Fetch the server-side diagnostic snapshot for this bundle. Pair with
88
- * `loadCustomerAppManifest()` — pass its result here. Logs the
194
+ * `loadCustomAppManifest()` — pass its result here. Logs the
89
195
  * snapshot through the SDK logger so it appears in the bundle's
90
196
  * console at info level.
91
197
  */
92
- async function getCustomerAppDebug(resolved) {
198
+ async function getCustomAppDebug(resolved) {
93
199
  const log = getOxyAppLogger();
94
200
  const { apiBaseUrl, orgSlug, appSlug } = resolved;
95
201
  const url = `${apiBaseUrl}/api/customer-apps/${encodeURIComponent(orgSlug)}/${encodeURIComponent(appSlug)}/debug`;
@@ -104,6 +210,547 @@ async function getCustomerAppDebug(resolved) {
104
210
  return snapshot;
105
211
  }
106
212
 
213
+ //#endregion
214
+ //#region src/custom-app/metric-tree-fetch.ts
215
+ /** Base path for the metric-tree endpoints of `projectId`. */
216
+ function metricTreePath(projectId) {
217
+ return `/api/projects/${projectId}/semantic/metric-tree`;
218
+ }
219
+ /** GET `url`, decoding JSON or throwing a typed {@link OxyApiError}. */
220
+ async function getJson(fetcher, url, signal) {
221
+ const resp = await fetcher(url, {
222
+ method: "GET",
223
+ signal
224
+ });
225
+ if (!resp.ok) throw await apiErrorFromResponse(resp);
226
+ return await resp.json();
227
+ }
228
+ /** POST `body` (tagged `v: 1`) to `url`, decoding JSON or throwing. */
229
+ async function postJson(fetcher, url, body, signal) {
230
+ const resp = await fetcher(url, {
231
+ method: "POST",
232
+ headers: { "content-type": "application/json" },
233
+ body: JSON.stringify({
234
+ v: 1,
235
+ ...body
236
+ }),
237
+ signal
238
+ });
239
+ if (!resp.ok) throw await apiErrorFromResponse(resp);
240
+ return await resp.json();
241
+ }
242
+
243
+ //#endregion
244
+ //#region src/custom-app/metric-tree-hooks.tsx
245
+ /**
246
+ * Internal engine shared by every metric-tree hook. Runs `run(signal)`
247
+ * whenever `key` changes (and on `refetch`), tracks loading/error, and
248
+ * cancels in-flight work on unmount or input change.
249
+ *
250
+ * `key` is the deep-compare fingerprint of the request; a `null` key
251
+ * means "no request yet" and leaves the hook idle without firing.
252
+ */
253
+ function useMetricTreeEndpoint(key, run, enabled) {
254
+ const [data, setData] = React.useState(null);
255
+ const [loading, setLoading] = React.useState(enabled && key !== null);
256
+ const [error, setError] = React.useState(null);
257
+ const [_nonce, setNonce] = React.useState(0);
258
+ const runRef = React.useRef(run);
259
+ runRef.current = run;
260
+ React.useEffect(() => {
261
+ if (!enabled || key === null) {
262
+ setLoading(false);
263
+ return;
264
+ }
265
+ const ctrl = new AbortController();
266
+ let cancelled = false;
267
+ setLoading(true);
268
+ setError(null);
269
+ runRef.current(ctrl.signal).then((result) => {
270
+ if (cancelled) return;
271
+ setData(result);
272
+ setLoading(false);
273
+ }).catch((err) => {
274
+ if (cancelled) return;
275
+ if (err instanceof DOMException && err.name === "AbortError") return;
276
+ setError(err instanceof Error ? err : new Error(String(err)));
277
+ setLoading(false);
278
+ });
279
+ return () => {
280
+ cancelled = true;
281
+ ctrl.abort();
282
+ };
283
+ }, [key, enabled]);
284
+ return {
285
+ data,
286
+ loading,
287
+ error,
288
+ refetch: React.useCallback(() => setNonce((n) => n + 1), [])
289
+ };
290
+ }
291
+ /**
292
+ * The project's metric tree — measures (nodes) and their component /
293
+ * driver relationships (edges) — or the subtree rooted at `opts.root`.
294
+ * The structural backbone every other metric-tree analysis reads against.
295
+ */
296
+ function useMetricTree(opts = {}) {
297
+ const { projectId, fetcher } = useOxyApp();
298
+ const enabled = opts.enabled !== false;
299
+ const root = opts.root;
300
+ return useMetricTreeEndpoint(projectId ? JSON.stringify({
301
+ projectId,
302
+ root
303
+ }) : null, (signal) => {
304
+ const qs = root ? `?root=${encodeURIComponent(root)}` : "";
305
+ return getJson(fetcher, `${metricTreePath(projectId)}${qs}`, signal);
306
+ }, enabled);
307
+ }
308
+ /**
309
+ * Ranked drivers of `measureId`, by influence — the "what moves this
310
+ * measure" question. Pass `null` to stay idle until a measure is chosen.
311
+ */
312
+ function useSensitivity(measureId, opts = {}) {
313
+ const { projectId, fetcher } = useOxyApp();
314
+ const enabled = opts.enabled !== false;
315
+ return useMetricTreeEndpoint(projectId && measureId ? JSON.stringify({
316
+ projectId,
317
+ measureId
318
+ }) : null, (signal) => {
319
+ const path = `${metricTreePath(projectId)}/${encodeURIComponent(measureId)}/sensitivity`;
320
+ return getJson(fetcher, path, signal);
321
+ }, enabled);
322
+ }
323
+ /**
324
+ * Propagate hypothetical `(measure, delta)` changes upward through the
325
+ * tree and return the estimated impact on every downstream measure — a
326
+ * pure metric-tree walk, no warehouse query. Pass `null` to stay idle.
327
+ */
328
+ function usePredict(changes, opts = {}) {
329
+ const { projectId, fetcher } = useOxyApp();
330
+ const enabled = opts.enabled !== false;
331
+ return useMetricTreeEndpoint(projectId && changes ? JSON.stringify({
332
+ projectId,
333
+ changes
334
+ }) : null, (signal) => postJson(fetcher, `${metricTreePath(projectId)}/predict`, { changes }, signal), enabled);
335
+ }
336
+ /**
337
+ * Period-over-period root-cause decomposition: recursively splits the
338
+ * target measure by components and dimensions until the move concentrates.
339
+ * This is the heavy one — it can fire many warehouse queries and the
340
+ * server caps it at 45s. Pass `null` to defer until periods are chosen.
341
+ */
342
+ function useExplain(request, opts = {}) {
343
+ const { projectId, fetcher } = useOxyApp();
344
+ const enabled = opts.enabled !== false;
345
+ return useMetricTreeEndpoint(projectId && request ? JSON.stringify({
346
+ projectId,
347
+ request
348
+ }) : null, (signal) => postJson(fetcher, `${metricTreePath(projectId)}/explain`, request, signal), enabled);
349
+ }
350
+ /**
351
+ * Single-period distribution of a measure — an {@link ExplainResult}
352
+ * against an auto-derived immediately-prior baseline. Same renderers as
353
+ * `useExplain`; ignore the delta fields for a pure distribution view.
354
+ */
355
+ function useDistribution(request, opts = {}) {
356
+ const { projectId, fetcher } = useOxyApp();
357
+ const enabled = opts.enabled !== false;
358
+ return useMetricTreeEndpoint(projectId && request ? JSON.stringify({
359
+ projectId,
360
+ request
361
+ }) : null, (signal) => postJson(fetcher, `${metricTreePath(projectId)}/distribution`, request, signal), enabled);
362
+ }
363
+ /**
364
+ * Segment opportunity sizing for a measure over a period: finds
365
+ * underperforming segments and sizes the addressable upside of closing
366
+ * each rate gap against a benchmark peer. Pass `null` to stay idle until
367
+ * a target + period are chosen.
368
+ */
369
+ function useOpportunity(request, opts = {}) {
370
+ const { projectId, fetcher } = useOxyApp();
371
+ const enabled = opts.enabled !== false;
372
+ return useMetricTreeEndpoint(projectId && request ? JSON.stringify({
373
+ projectId,
374
+ request
375
+ }) : null, (signal) => postJson(fetcher, `${metricTreePath(projectId)}/opportunity`, request, signal), enabled);
376
+ }
377
+ /**
378
+ * The queryable time dimensions per view (`view.dim` ids) — what a
379
+ * bundle offers as the period axis for `explain` / `opportunity` /
380
+ * `distribution` instead of hardcoding a curated map.
381
+ */
382
+ function useTimeDimensions(opts = {}) {
383
+ const { projectId, fetcher } = useOxyApp();
384
+ const enabled = opts.enabled !== false;
385
+ return useMetricTreeEndpoint(projectId ? JSON.stringify({
386
+ projectId,
387
+ kind: "time-dimensions"
388
+ }) : null, (signal) => getJson(fetcher, `${metricTreePath(projectId)}/time-dimensions`, signal), enabled);
389
+ }
390
+
391
+ //#endregion
392
+ //#region src/custom-app/sse.ts
393
+ /**
394
+ * Read a `text/event-stream` response, invoking `onEvent` with each parsed
395
+ * JSON frame. Frames that fail to parse are skipped (a malformed frame must
396
+ * not tear down the whole stream). Resolves when the body closes.
397
+ */
398
+ async function readJsonSseStream(resp, onEvent) {
399
+ const reader = resp.body?.getReader();
400
+ if (!reader) throw new Error("SSE response has no body stream");
401
+ const decoder = new TextDecoder();
402
+ let buffer = "";
403
+ for (;;) {
404
+ const { done, value } = await reader.read();
405
+ if (done) break;
406
+ buffer += decoder.decode(value, { stream: true });
407
+ let sep;
408
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
409
+ const frame = buffer.slice(0, sep);
410
+ buffer = buffer.slice(sep + 2);
411
+ let data = "";
412
+ for (const line of frame.split("\n")) if (line.startsWith("data:")) data += line.slice(5).trim();
413
+ if (!data) continue;
414
+ let parsed;
415
+ try {
416
+ parsed = JSON.parse(data);
417
+ } catch {
418
+ continue;
419
+ }
420
+ onEvent(parsed);
421
+ }
422
+ }
423
+ }
424
+
425
+ //#endregion
426
+ //#region src/custom-app/world-model-hooks.tsx
427
+ /** Base path for the world-model endpoints of the active project. */
428
+ function worldModelPath(projectId) {
429
+ return `/api/projects/${projectId}/semantic/world-model`;
430
+ }
431
+ /**
432
+ * The world-model graph — entities (nodes), their measures/dimensions, and
433
+ * how measures promote across the entity hierarchy (edges). Applies the
434
+ * project's `.world-model.yml` display config server-side.
435
+ *
436
+ * @remarks
437
+ * This returns the raw semantic-layer entity graph. For the higher-level
438
+ * node-paradigm interface (`world.metric(id)` speaking `expand` / `explain` /
439
+ * `size`), use {@link useWorldModel} from `./world-node` instead.
440
+ */
441
+ function useWorldModelGraph(opts = {}) {
442
+ const { projectId, fetcher } = useOxyApp();
443
+ const enabled = opts.enabled !== false;
444
+ const [data, setData] = React.useState(null);
445
+ const [loading, setLoading] = React.useState(enabled && !!projectId);
446
+ const [error, setError] = React.useState(null);
447
+ const [_nonce, setNonce] = React.useState(0);
448
+ React.useEffect(() => {
449
+ if (!enabled || !projectId) {
450
+ setLoading(false);
451
+ return;
452
+ }
453
+ const ctrl = new AbortController();
454
+ let cancelled = false;
455
+ setLoading(true);
456
+ setError(null);
457
+ fetcher(worldModelPath(projectId), {
458
+ method: "GET",
459
+ signal: ctrl.signal
460
+ }).then(async (resp) => {
461
+ if (!resp.ok) throw await apiErrorFromResponse(resp);
462
+ return await resp.json();
463
+ }).then((result) => {
464
+ if (cancelled) return;
465
+ setData(result);
466
+ setLoading(false);
467
+ }).catch((err) => {
468
+ if (cancelled) return;
469
+ if (err instanceof DOMException && err.name === "AbortError") return;
470
+ setError(err instanceof Error ? err : new Error(String(err)));
471
+ setLoading(false);
472
+ });
473
+ return () => {
474
+ cancelled = true;
475
+ ctrl.abort();
476
+ };
477
+ }, [
478
+ enabled,
479
+ projectId,
480
+ fetcher
481
+ ]);
482
+ return {
483
+ data,
484
+ loading,
485
+ error,
486
+ refetch: React.useCallback(() => setNonce((n) => n + 1), [])
487
+ };
488
+ }
489
+ /**
490
+ * List the instances (rows) of `entityId` — a bounded, searchable picker
491
+ * over the entity's primary keys + display label. Pass `null` for `entityId`
492
+ * to stay idle until an entity is chosen.
493
+ */
494
+ function useWorldModelInstances(entityId, opts = {}) {
495
+ const { projectId, fetcher } = useOxyApp();
496
+ const enabled = opts.enabled !== false;
497
+ const { search, limit } = opts;
498
+ const [data, setData] = React.useState(null);
499
+ const [loading, setLoading] = React.useState(enabled && !!projectId && !!entityId);
500
+ const [error, setError] = React.useState(null);
501
+ const [_nonce, setNonce] = React.useState(0);
502
+ React.useEffect(() => {
503
+ if (!enabled || !projectId || !entityId) {
504
+ setLoading(false);
505
+ return;
506
+ }
507
+ const ctrl = new AbortController();
508
+ let cancelled = false;
509
+ setLoading(true);
510
+ setError(null);
511
+ const params = new URLSearchParams({ entity: entityId });
512
+ if (search) params.set("search", search);
513
+ if (limit != null) params.set("limit", String(limit));
514
+ fetcher(`${worldModelPath(projectId)}/instances?${params}`, {
515
+ method: "GET",
516
+ signal: ctrl.signal
517
+ }).then(async (resp) => {
518
+ if (!resp.ok) throw await apiErrorFromResponse(resp);
519
+ return await resp.json();
520
+ }).then((result) => {
521
+ if (cancelled) return;
522
+ setData(result);
523
+ setLoading(false);
524
+ }).catch((err) => {
525
+ if (cancelled) return;
526
+ if (err instanceof DOMException && err.name === "AbortError") return;
527
+ setError(err instanceof Error ? err : new Error(String(err)));
528
+ setLoading(false);
529
+ });
530
+ return () => {
531
+ cancelled = true;
532
+ ctrl.abort();
533
+ };
534
+ }, [
535
+ enabled,
536
+ projectId,
537
+ entityId,
538
+ search,
539
+ limit,
540
+ fetcher
541
+ ]);
542
+ return {
543
+ data,
544
+ loading,
545
+ error,
546
+ refetch: React.useCallback(() => setNonce((n) => n + 1), [])
547
+ };
548
+ }
549
+ /** Fold one measure-breakdown SSE frame into the accumulated graph. */
550
+ function foldBreakdown(prev, ev) {
551
+ switch (ev.kind) {
552
+ case "init": return {
553
+ root: ev.root,
554
+ nodes: ev.nodes.map((n) => ({
555
+ ...n,
556
+ value: null,
557
+ unvalued_reason: null
558
+ })),
559
+ edges: ev.edges
560
+ };
561
+ case "value":
562
+ if (!prev) return prev;
563
+ return {
564
+ ...prev,
565
+ nodes: prev.nodes.map((n) => n.id === ev.node_id ? {
566
+ ...n,
567
+ value: ev.value,
568
+ unvalued_reason: ev.unvalued_reason
569
+ } : n)
570
+ };
571
+ default: return prev;
572
+ }
573
+ }
574
+ /**
575
+ * Stream the driver-tree breakdown of one instance's measure — the metric
576
+ * decomposition (add/sub/mul/div component graph) with each node's value
577
+ * filling in as it resolves. This is the per-instance RCA view. Pass `null`
578
+ * for `measure` to stay idle.
579
+ */
580
+ function useMeasureBreakdown(entityId, keyValue, measure) {
581
+ const { projectId, fetcher } = useOxyApp();
582
+ const [breakdown, setBreakdown] = React.useState(null);
583
+ const [loading, setLoading] = React.useState(false);
584
+ const [done, setDone] = React.useState(false);
585
+ const [error, setError] = React.useState(null);
586
+ React.useEffect(() => {
587
+ if (!projectId || !entityId || !keyValue || !measure) {
588
+ setLoading(false);
589
+ return;
590
+ }
591
+ const ctrl = new AbortController();
592
+ let cancelled = false;
593
+ setBreakdown(null);
594
+ setLoading(true);
595
+ setDone(false);
596
+ setError(null);
597
+ const params = new URLSearchParams({
598
+ entity: entityId,
599
+ key: keyValue,
600
+ measure
601
+ });
602
+ fetcher(`${worldModelPath(projectId)}/measure-breakdown?${params}`, {
603
+ method: "GET",
604
+ signal: ctrl.signal
605
+ }).then(async (resp) => {
606
+ if (!resp.ok) throw await apiErrorFromResponse(resp);
607
+ await readJsonSseStream(resp, (ev) => {
608
+ if (cancelled) return;
609
+ if (ev.kind === "done") {
610
+ setDone(true);
611
+ return;
612
+ }
613
+ setBreakdown((prev) => foldBreakdown(prev, ev));
614
+ });
615
+ if (!cancelled) setLoading(false);
616
+ }).catch((err) => {
617
+ if (cancelled) return;
618
+ if (err instanceof DOMException && err.name === "AbortError") return;
619
+ setError(err instanceof Error ? err : new Error(String(err)));
620
+ setLoading(false);
621
+ });
622
+ return () => {
623
+ cancelled = true;
624
+ ctrl.abort();
625
+ };
626
+ }, [
627
+ projectId,
628
+ entityId,
629
+ keyValue,
630
+ measure,
631
+ fetcher
632
+ ]);
633
+ return {
634
+ breakdown,
635
+ loading,
636
+ done,
637
+ error
638
+ };
639
+ }
640
+
641
+ //#endregion
642
+ //#region src/custom-app/world-node.tsx
643
+ /**
644
+ * Thrown by the value verbs (`explain` / `size`) when called on a handle that
645
+ * has been `drill`ed. The metric-tree backend cannot yet scope these analyses
646
+ * to a segment, so failing loud beats returning population numbers for a
647
+ * question that asked about one segment.
648
+ */
649
+ var WorldModelScopeUnsupportedError = class extends Error {
650
+ constructor(verb, scope) {
651
+ super(`${verb} on a drilled (scoped) node is not yet supported by the backend (scope: ${JSON.stringify(scope)}). Call ${verb} on the un-drilled node for population-level analysis.`);
652
+ this.code = "world_model_scope_unsupported";
653
+ this.name = "WorldModelScopeUnsupportedError";
654
+ this.scope = scope;
655
+ }
656
+ };
657
+ /**
658
+ * Build a {@link WorldModelApi} over a project id and fetcher. Framework-
659
+ * agnostic — `useWorldModel()` wraps this for React, but it is directly
660
+ * unit-testable with a mock fetcher.
661
+ */
662
+ function createWorldModel(projectId, fetcher) {
663
+ const base = () => {
664
+ if (!projectId) throw new Error("World Model unavailable: no active project (are you inside <OxyAppProvider>?)");
665
+ return metricTreePath(projectId);
666
+ };
667
+ const tree = (root, signal) => {
668
+ const qs = root ? `?root=${encodeURIComponent(root)}` : "";
669
+ return getJson(fetcher, `${base()}${qs}`, signal);
670
+ };
671
+ const makeHandle = (id, scope) => {
672
+ const scoped = Object.keys(scope).length > 0;
673
+ return {
674
+ id,
675
+ scope,
676
+ async node(signal) {
677
+ const found = (await tree(id, signal)).nodes.find((n) => n.id === id);
678
+ if (!found) throw new Error(`measure '${id}' not found in the metric tree`);
679
+ return found;
680
+ },
681
+ async expand(signal) {
682
+ const t = await tree(id, signal);
683
+ const byId = new Map(t.nodes.map((n) => [n.id, n]));
684
+ const children = [];
685
+ for (const edge of t.edges) {
686
+ if (edge.from !== id) continue;
687
+ const childNode = byId.get(edge.to);
688
+ if (!childNode) continue;
689
+ children.push({
690
+ node: childNode,
691
+ edge,
692
+ handle: makeHandle(edge.to, scope)
693
+ });
694
+ }
695
+ return children;
696
+ },
697
+ drivers(signal) {
698
+ return getJson(fetcher, `${base()}/${encodeURIComponent(id)}/sensitivity`, signal);
699
+ },
700
+ explain(opts, signal) {
701
+ if (scoped) throw new WorldModelScopeUnsupportedError("explain", scope);
702
+ return postJson(fetcher, `${base()}/explain`, {
703
+ target: id,
704
+ ...opts
705
+ }, signal);
706
+ },
707
+ size(opts, signal) {
708
+ if (scoped) throw new WorldModelScopeUnsupportedError("size", scope);
709
+ return postJson(fetcher, `${base()}/opportunity`, {
710
+ target: id,
711
+ ...opts
712
+ }, signal);
713
+ },
714
+ drill(next) {
715
+ return makeHandle(id, {
716
+ ...scope,
717
+ ...next
718
+ });
719
+ }
720
+ };
721
+ };
722
+ return {
723
+ projectId,
724
+ tree,
725
+ metric: (id) => makeHandle(id, {})
726
+ };
727
+ }
728
+ /**
729
+ * The World Model node interface, scoped to the active `<OxyAppProvider>`
730
+ * project. Returns a stable {@link WorldModelApi} — grab a node with
731
+ * `world.metric(id)` and let it speak the verbs.
732
+ *
733
+ * @example
734
+ * ```tsx
735
+ * const world = useWorldModel();
736
+ * const revenue = world.metric("orders.net_revenue");
737
+ * const children = await revenue.expand(); // components + drivers
738
+ * const rca = await revenue.explain({
739
+ * time_dimension: "orders.order_date",
740
+ * current_period: ["2026-06-01", "2026-06-30"],
741
+ * previous_period: ["2026-05-01", "2026-05-31"],
742
+ * });
743
+ * ```
744
+ *
745
+ * @remarks
746
+ * This is the node-paradigm hook. For the raw semantic-layer entity/measure
747
+ * graph, use {@link useWorldModelGraph} instead.
748
+ */
749
+ function useWorldModel() {
750
+ const { projectId, fetcher } = useOxyApp();
751
+ return React.useMemo(() => createWorldModel(projectId ?? null, fetcher), [projectId, fetcher]);
752
+ }
753
+
107
754
  //#endregion
108
755
  //#region src/metricTree.ts
109
756
  /**
@@ -233,5 +880,5 @@ var MetricTreeClient = class {
233
880
  };
234
881
 
235
882
  //#endregion
236
- export { AnomaliesClient, MetricTreeClient, OxyAnswer, OxyApiError, OxyAppProvider, OxyChat, _resetCustomerAppManifestCacheForTest, apiErrorFromResponse, getCustomerAppDebug, getOxyAppLogger, interpretCustomerAppError, loadCustomerAppManifest, readInjectedAppConfig, setOxyAppLogger, useAgentRun, useFunction, useOxyApp, useProcedureRun, useQuery, useResolvedManifest, useSemanticQuery, useTrackEvent };
883
+ export { AnomaliesClient, MetricTreeClient, OxyAnswer, OxyApiError, OxyAppProvider, OxyChat, WorldModelScopeUnsupportedError, _resetCustomAppManifestCacheForTest, apiErrorFromResponse, base64ToBytes, bytesToBase64, createWorldModel, getCustomAppDebug, getOxyAppLogger, interpretCustomAppError, loadCustomAppManifest, readInjectedAppConfig, readJsonSseStream, setOxyAppLogger, useAgentRun, useDistribution, useExplain, useFunction, useMeasureBreakdown, useMetricTree, useOpportunity, useOxyApp, usePredict, useProcedureRun, useQuery, useResolvedManifest, useSemanticQuery, useSensitivity, useTimeDimensions, useTrackEvent, useWorldModel, useWorldModelGraph, useWorldModelInstances };
237
884
  //# sourceMappingURL=index.mjs.map