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