@oxy-hq/sdk 2.8.0 → 2.10.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  React SDK for building **custom-app bundles** on the [Oxy](https://oxygen-hq.com)
4
4
  platform. A bundle is a normal Vite + React app that reads from its linked oxy
5
- project — raw SQL, the semantic layer, agents, and procedures — through a
5
+ project — raw SQL, the semantic model, agents, and procedures — through a
6
6
  small set of hooks, plus a couple of drop-in components.
7
7
 
8
8
  > **v2 is a complete rewrite.** The v1 stack (`OxyClient` / `OxySDK`, the
@@ -80,7 +80,7 @@ couldn't already read.
80
80
  | --- | --- |
81
81
  | `OxyAppProvider` | Resolves identity, provides it via context. `fallback` renders while loading; `errorFallback` gets a structured error report. |
82
82
  | `useQuery({ sql })` | Inline SQL → rows. `SELECT`/`WITH` only, 10k-row cap. |
83
- | `useSemanticQuery({ topic, dimensions, measures, … })` | Semantic-layer query compiled by airlayer. |
83
+ | `useSemanticQuery({ topic, dimensions, measures, … })` | Semantic-model query compiled by airlayer. |
84
84
  | `useAgentRun({ agentId })` | `.ask(question)` starts an analytics agent run; streams events over SSE; `.cancel()`. |
85
85
  | `useProcedureRun({ procedureId })` | Start a long-running procedure, poll, cancel (beta). |
86
86
  | `useFunction(name)` | `.invoke(body?)` runs a server-side **Oxy Function** (`functions/<name>.ts`) on oxy's isolate runtime; returns its JSON `Response`. For work the browser shouldn't do — warehouse writes, ELT, external APIs. |
@@ -188,12 +188,67 @@ global styles leak into your app. It follows your design tokens when present
188
188
  (`--sidebar-background`, `--foreground`, …) and falls back to the Oxygen
189
189
  defaults. Dark mode: put a `.dark` class on any ancestor.
190
190
 
191
+ ## Who is using the app
192
+
193
+ Two identity surfaces, and the difference between them is the difference between
194
+ a decision and a greeting.
195
+
196
+ **`ctx.user`, inside an Oxy Function — authoritative.** Assembled server-side per
197
+ invocation from the authenticated session, so nothing on it is client-supplied.
198
+ This is where a check that matters goes:
199
+
200
+ ```ts
201
+ import type { OxyFunctionContext, OxyFunctionRequest } from "@oxy-hq/sdk";
202
+
203
+ export default async function exportAll(req: OxyFunctionRequest, ctx: OxyFunctionContext) {
204
+ if (ctx.user.appRole !== "admin") {
205
+ return Response.json({ error: "forbidden" }, { status: 403 });
206
+ }
207
+ return Response.json({ rows: await dump(ctx) });
208
+ }
209
+ ```
210
+
211
+ | Field | Notes |
212
+ | --- | --- |
213
+ | `id`, `email`, `orgId` | Always present. `orgId` is the tenant boundary for anything you query. |
214
+ | `name`, `picture` | Display identity. Absent on schedule/Airway runs. |
215
+ | `appRole` | `"admin"` \| `"member"` \| absent. **The one to gate on** — an app grant (direct or via a team), with org-officer / Oxy-staff break-glass. Fails closed. |
216
+ | `orgRole` | `"owner"` \| `"admin"` \| `"member"` \| absent. Informational: explain ("ask your org admin"), label, route. Not a gate — org standing and app standing are different things. |
217
+ | `teams` | Org teams they belong to, name-sorted, scoped to this org. Descriptive — a team only grants anything through an app team grant, which `appRole` already reflects. |
218
+ | `kind` | `"user"` \| `"system"`. |
219
+
220
+ `teams` and `kind` are typed optional because a server older than 2026-08-21
221
+ doesn't send them — use `ctx.user.teams?.some(...)`. For `kind` there is no safe
222
+ inference on such a server (`=== "system"` misses a cron, `!== "user"` misfires
223
+ on a person), so if you support one, mark the schedule's configured `input`
224
+ instead of guessing.
225
+
226
+ **Background runs have no caller to attribute them to.** A schedule tick, an
227
+ Airway step, and an operator's manual *Run now* all run under the org owner's
228
+ `id` with `kind: "system"`, every caller field absent, and a synthetic
229
+ `schedule+<fn>@system.oxy` email — but `appRole` still reads `"admin"`, since
230
+ they carry owner authority. Note the manual case: a person did click, and there
231
+ is still nobody to reach, because the triggering operator isn't carried through
232
+ the job queue. A function wired to both a route and a background trigger must
233
+ branch on `kind`, not on the email:
234
+
235
+ ```ts
236
+ if (ctx.user.kind === "system") return runRollup(ctx); // no one to email
237
+ await ctx.email.send({ to: ctx.user.email, subject: `Hi ${ctx.user.name}`, html });
238
+ ```
239
+
240
+ **`useShellContext()`, in the bundle — display only.** `data.user` is
241
+ `{ name, email, picture } | null`, and it deliberately carries no role at all.
242
+ Use it for an avatar or a greeting. Hiding a tab with it is fine; the endpoint
243
+ behind that tab is what actually has to say no.
244
+
191
245
  ## Docs
192
246
 
193
247
  - Hands-on dev + deploy guide: `docs/local-development.md` in the
194
248
  [`oxy-hq/customer-apps`](https://github.com/oxy-hq/customer-apps) repo.
195
249
  - SDK flow reference: `docs/sdk-flow.md` in that repo.
196
- - Platform internals: `internal-docs/customer-apps.md` in oxygen-internal.
250
+ - Platform internals: `internal-docs/customer-apps.md` and
251
+ `internal-docs/custom-apps-user-identity.md` in oxygen-internal.
197
252
 
198
253
 
199
254
  ## License
@@ -1 +1 @@
1
- {"version":3,"file":"email.cjs","names":[],"sources":["../src/email.ts"],"sourcesContent":["// `@oxy-hq/sdk/email` — render an email template to an HTML string for use in\n// an Oxy Function's `ctx.email.send`.\n//\n// You write email templates as plain JSX components (point JSX at preact with\n// `jsxImportSource: \"preact\"` in the template's tsconfig — see the examples),\n// then:\n//\n// ```ts\n// import { render } from \"@oxy-hq/sdk/email\";\n// import { Welcome } from \"../emails/Welcome\";\n// await ctx.email.send({ to, subject, html: render(Welcome, { name }) });\n// ```\n//\n// Rendering uses preact-render-to-string — pure JS, no react-dom/server, no node\n// builtins, no Web Streams — so it bundles under esbuild `--platform=neutral`\n// and runs inside the Oxy Functions isolate (React Email / react-dom cannot).\n// Kept in a separate subpath entry so it never bloats the main SDK bundle.\n\nimport { type ComponentType, h } from \"preact\";\nimport { render as prerender } from \"preact-render-to-string\";\n\n/** Render an email template component to an HTML string. */\nexport function render<P extends Record<string, unknown>>(\n Component: ComponentType<P>,\n props: P\n): string {\n return prerender(h(Component, props));\n}\n"],"mappings":";;;;;;;AAsBA,SAAgB,OACd,WACA,OACQ;CACR,yDAAmB,WAAW,KAAK,CAAC;AACtC"}
1
+ {"version":3,"file":"email.cjs","names":["prerender","h"],"sources":["../src/email.ts"],"sourcesContent":["// `@oxy-hq/sdk/email` — render an email template to an HTML string for use in\n// an Oxy Function's `ctx.email.send`.\n//\n// You write email templates as plain JSX components (point JSX at preact with\n// `jsxImportSource: \"preact\"` in the template's tsconfig — see the examples),\n// then:\n//\n// ```ts\n// import { render } from \"@oxy-hq/sdk/email\";\n// import { Welcome } from \"../emails/Welcome\";\n// await ctx.email.send({ to, subject, html: render(Welcome, { name }) });\n// ```\n//\n// Rendering uses preact-render-to-string — pure JS, no react-dom/server, no node\n// builtins, no Web Streams — so it bundles under esbuild `--platform=neutral`\n// and runs inside the Oxy Functions isolate (React Email / react-dom cannot).\n// Kept in a separate subpath entry so it never bloats the main SDK bundle.\n\nimport { type ComponentType, h } from \"preact\";\nimport { render as prerender } from \"preact-render-to-string\";\n\n/** Render an email template component to an HTML string. */\nexport function render<P extends Record<string, unknown>>(\n Component: ComponentType<P>,\n props: P\n): string {\n return prerender(h(Component, props));\n}\n"],"mappings":";;;;;;;AAsBA,SAAgB,OACd,WACA,OACQ;CACR,WAAOA,oCAAUC,UAAE,WAAW,KAAK,CAAC;AACtC"}
package/dist/index.cjs CHANGED
@@ -1,10 +1,20 @@
1
1
  // @oxy/sdk - TypeScript SDK for Oxy data platform
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
3
- const require_react = require('./react-BFFCK4VM.cjs');
3
+ const require_react = require('./react-Cas_DVKe.cjs');
4
4
  let react = require("react");
5
5
  react = require_react.__toESM(react, 1);
6
6
 
7
7
  //#region src/anomalies.ts
8
+ /** Which buckets a write may touch when the caller didn't say. Live statuses
9
+ * for ack/dismiss; all three for a reopen, which exists to reach dismissed
10
+ * ones. */
11
+ function defaultScope(status) {
12
+ return status === "new" ? [
13
+ "new",
14
+ "acknowledged",
15
+ "dismissed"
16
+ ] : ["new", "acknowledged"];
17
+ }
8
18
  /**
9
19
  * Client for `/semantic/anomalies*`. Construct via `OxyClient.anomalies`
10
20
  * rather than instantiating directly — the getter wires the request helper
@@ -33,18 +43,25 @@ var AnomaliesClient = class {
33
43
  return qs ? `?${qs}` : "";
34
44
  }
35
45
  /**
36
- * List anomalies in the inbox, newest first.
46
+ * List anomalies in the inbox, ranked worst-first by event severity (active
47
+ * events before dismissed). Pass `order: "recent"` for latest-first.
37
48
  *
38
49
  * @example
39
50
  * ```typescript
40
51
  * // Open / unresolved anomalies only
41
52
  * const { anomalies } = await client.anomalies.list({ status: "new" });
53
+ *
54
+ * // Second page of 25 events
55
+ * const page2 = await client.anomalies.list({ limit: 25, offset: 25 });
56
+ * console.log(`${(page2.offset ?? 25) + 1}+ of ${page2.total ?? "?"}`);
42
57
  * ```
43
58
  */
44
59
  async list(options = {}) {
45
60
  const extra = {};
46
61
  if (options.status) extra.status = options.status;
47
- if (options.limit) extra.limit = String(options.limit);
62
+ if (options.limit !== void 0) extra.limit = String(options.limit);
63
+ if (options.offset !== void 0) extra.offset = String(options.offset);
64
+ if (options.order) extra.order = options.order;
48
65
  return this.request(this.path(this.buildQuery(extra)));
49
66
  }
50
67
  /**
@@ -84,6 +101,51 @@ var AnomaliesClient = class {
84
101
  });
85
102
  }
86
103
  /**
104
+ * Update many anomalies in one request — the batch form of
105
+ * {@link updateStatus}. Identifiers outside the workspace are skipped rather
106
+ * than erroring, so `updated` (rows written) can be lower than what you sent.
107
+ * At most 2000 identifiers across both lists.
108
+ *
109
+ * **Prefer `eventIds`.** Inbox actions are per *event*, and a list response
110
+ * caps how many buckets it returns per event — so acking the bucket ids you
111
+ * received can leave the tail of a long chain behind, `new`, under a clean
112
+ * success. Naming the event lets the server write all of it. `ids` is for
113
+ * rows with no `event_id` (detected before events existed), which can only
114
+ * be named individually.
115
+ *
116
+ * `onlyStatuses` says which of an event's buckets may move. An event can span
117
+ * statuses, so an unbounded write resurrects buckets that were dismissed on
118
+ * purpose — which is why omitting it takes a scope rather than no bound at
119
+ * all: the live statuses (`["new", "acknowledged"]`) for an ack or dismiss,
120
+ * and all three for `status: "new"`, since reopening is how a dismissed
121
+ * anomaly comes back. The server applies that same default, so the safe
122
+ * behaviour does not depend on going through this client. Pass `[]` to opt
123
+ * out of the bound entirely.
124
+ *
125
+ * @example
126
+ * ```typescript
127
+ * const { anomalies } = await client.anomalies.list({ status: "new", limit: 50, offset: 0 });
128
+ * // Both lists: events by id, and pre-event rows (no `event_id`) by their own.
129
+ * const eventIds = [...new Set(anomalies.flatMap((a) => (a.event_id ? [a.event_id] : [])))];
130
+ * const ids = anomalies.filter((a) => !a.event_id).map((a) => a.id);
131
+ * const { updated } = await client.anomalies.updateStatusBulk(
132
+ * { ids, eventIds, onlyStatuses: ["new", "acknowledged"] },
133
+ * "acknowledged"
134
+ * );
135
+ * ```
136
+ */
137
+ async updateStatusBulk(target, status) {
138
+ return this.request(this.path(`/status${this.buildQuery()}`), {
139
+ method: "POST",
140
+ body: JSON.stringify({
141
+ ids: target.ids ?? [],
142
+ event_ids: target.eventIds ?? [],
143
+ only_statuses: target.onlyStatuses ?? defaultScope(status),
144
+ status
145
+ })
146
+ });
147
+ }
148
+ /**
87
149
  * Run the metric-tree `explain` for an anomaly and cache the result on
88
150
  * the row. Subsequent calls return the cached `ExplainResult` instantly;
89
151
  * pass `{ refresh: true }` to bust the cache and recompute.
@@ -436,7 +498,7 @@ function worldModelPath(projectId) {
436
498
  * project's `.world-model.yml` display config server-side.
437
499
  *
438
500
  * @remarks
439
- * This returns the raw semantic-layer entity graph. For the higher-level
501
+ * This returns the raw semantic-model entity graph. For the higher-level
440
502
  * node-paradigm interface (`world.metric(id)` speaking `expand` / `explain` /
441
503
  * `size`), use {@link useWorldModel} from `./world-node` instead.
442
504
  */
@@ -745,7 +807,7 @@ function createWorldModel(projectId, fetcher) {
745
807
  * ```
746
808
  *
747
809
  * @remarks
748
- * This is the node-paradigm hook. For the raw semantic-layer entity/measure
810
+ * This is the node-paradigm hook. For the raw semantic-model entity/measure
749
811
  * graph, use {@link useWorldModelGraph} instead.
750
812
  */
751
813
  function useWorldModel() {