@incorta/sdk 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Incorta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,283 @@
1
+ # @incorta/sdk
2
+
3
+ [![npm version](https://img.shields.io/npm/v/%40incorta%2Fsdk)](https://www.npmjs.com/package/@incorta/sdk)
4
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
5
+
6
+ Read Incorta **schemas, tables, views, and columns** from Node.js, as the
7
+ **signed-in user**. Sessions come from
8
+ [`@incorta/auth`](https://www.npmjs.com/package/@incorta/auth) — OAuth 2.0
9
+ against the authorization server built into Incorta — so this client has no
10
+ identity of its own and no personal access token to store.
11
+
12
+ The Python twin is [`incorta-sdk`](../sdk-python/README.md); the two speak the
13
+ same API, model the same distinctions, and share the same `INCORTA_*`
14
+ configuration.
15
+
16
+ ```bash
17
+ npm install @incorta/sdk
18
+ ```
19
+
20
+ ## Why it is scoped to a user
21
+
22
+ Every call carries a user's own Incorta access token, so Incorta filters the
23
+ results: two people hitting the same endpoint of your app see two different
24
+ catalogs. That is a property of the design, not a setting — there is no
25
+ app-level identity to over-share from, and nothing to revoke separately when
26
+ someone leaves.
27
+
28
+ It also means the *scoped* client, not the top-level one, is what you hold:
29
+
30
+ ```text
31
+ IncortaClient configuration + OAuth (build once, at startup)
32
+ └── forRequest() → IncortaUserClient (build per request)
33
+ ├── schemas
34
+ └── tables
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ `createIncortaClient()` takes every `@incorta/auth` option, each falling back to
40
+ its environment variable:
41
+
42
+ | Option | Environment variable | Meaning |
43
+ | --- | --- | --- |
44
+ | `incortaUrl` | `INCORTA_URL` | Environment root **including** any context path (often `/incorta`), **without** `/api/v2` |
45
+ | `tenant` | `INCORTA_TENANT` | Tenant name, e.g. `default` |
46
+ | `clientId` | `INCORTA_CLIENT_ID` | OAuth client id (see `incorta-auth register`) |
47
+ | `clientSecret` | `INCORTA_CLIENT_SECRET` | OAuth client secret |
48
+ | `secret` | `INCORTA_AUTH_SECRET` | Session-cookie encryption key (≥ 32 chars) |
49
+ | `internalIncortaUrl` | `INCORTA_INTERNAL_URL` | Optional split-horizon address for server-to-server calls |
50
+
51
+ Plus two of its own: `timeoutMs` (default `30000`) and `maxRetries` (default
52
+ `3`, for 429/5xx and network errors only — client errors are never retried).
53
+
54
+ When the app already builds an `IncortaAuth` — the usual case, since it needs
55
+ one to serve logins — pass it in rather than letting this package create a
56
+ second:
57
+
58
+ ```ts
59
+ import { createIncortaAuth } from "@incorta/auth";
60
+ import { createIncortaClient } from "@incorta/sdk";
61
+
62
+ const auth = createIncortaAuth({ appAccess: "catalog" });
63
+ const client = createIncortaClient({ auth });
64
+ ```
65
+
66
+ ## Usage
67
+
68
+ ### Express
69
+
70
+ ```ts
71
+ import express from "express";
72
+ import { incortaAuth, requireAuth } from "@incorta/auth/express";
73
+ import { createIncortaClient } from "@incorta/sdk";
74
+
75
+ const client = createIncortaClient(); // once, at startup
76
+ const app = express();
77
+
78
+ // The client owns an auth instance — this serves /auth/* and attaches the
79
+ // session to req.incortaAuth.
80
+ app.use(incortaAuth(client.auth));
81
+
82
+ app.get("/api/schemas", requireAuth(client.auth), async (req, res) => {
83
+ const incorta = client.forSession(req.incortaAuth!); // per request, as this user
84
+ res.json(await incorta.schemas.physical());
85
+ });
86
+
87
+ app.get("/api/tables/:schema/:table", requireAuth(client.auth), async (req, res) => {
88
+ const incorta = client.forSession(req.incortaAuth!);
89
+ const object = await incorta.tables.get(req.params.schema, req.params.table);
90
+ res.json({
91
+ name: object.qualifiedName,
92
+ columns: object.columns.map((c) => ({ name: c.name, type: c.dataType })),
93
+ });
94
+ });
95
+ ```
96
+
97
+ ### TanStack Start / anything with web-standard `Request`
98
+
99
+ ```ts
100
+ export const ServerRoute = createServerFileRoute().methods({
101
+ GET: async ({ request }) => {
102
+ const incorta = await client.forRequest(request);
103
+ return Response.json(await incorta.schemas.list());
104
+ },
105
+ });
106
+ ```
107
+
108
+ Given a session you already read (from a gate or a middleware), use
109
+ `client.forSession(session)` instead.
110
+
111
+ ## Surface
112
+
113
+ ### `createIncortaClient(config)` → `IncortaClient`
114
+
115
+ | Member | Purpose |
116
+ | --- | --- |
117
+ | `auth` | The underlying `IncortaAuth` — mount `auth.handler` / `auth.gate` |
118
+ | `forRequest(request, opts?)` | Scoped client for the user behind a `Request` |
119
+ | `forSession(session, opts?)` | Scoped client from a session you already read |
120
+ | `forAccessToken(token, opts?)` | Scoped client from a bare access token |
121
+ | `info` | `baseUrl`, `tenant`, `timeoutMs`, `maxRetries` — no secrets |
122
+
123
+ ### `IncortaUserClient.schemas`
124
+
125
+ | Method | Returns |
126
+ | --- | --- |
127
+ | `list({ type, limit, offset, sortBy })` | `SchemaInfo[]` |
128
+ | `listPage(...)` | `Page<SchemaInfo>` — adds the server-side `total` |
129
+ | `iterAll({ type, pageSize })` | `AsyncGenerator`, one page fetched at a time |
130
+ | `physical()` / `business()` | Shorthands for the type filter |
131
+ | `get(name)` | `PhysicalSchema \| BusinessSchema`, with contents |
132
+ | `exists(name)` | `boolean` |
133
+
134
+ ### `IncortaUserClient.tables`
135
+
136
+ | Method | Returns |
137
+ | --- | --- |
138
+ | `get(schema, name)` | `Table \| View`, columns populated |
139
+ | `list(schema)` / `names(schema)` | Every object, or just their names |
140
+ | `columns(schema, name)` | `Column[]` |
141
+ | `tablesOnly(schema)` / `viewsOnly(schema)` | Filtered by kind |
142
+ | `exists(schema, name)` | `boolean` |
143
+
144
+ Names are matched case-insensitively.
145
+
146
+ ### `IncortaUserClient.data`
147
+
148
+ Reads rows out of business views. Fields are addressed by their fully qualified
149
+ name, `SCHEMA.VIEW.COLUMN`.
150
+
151
+ | Method | Returns |
152
+ | --- | --- |
153
+ | `query(measures, options?)` | `Promise<QueryResult>` |
154
+ | `iterRows(measures, options?)` | Async generator of rows, one page at a time |
155
+ | `csv(measures, options?)` | `Promise<string>` — the CSV Incorta rendered |
156
+ | `raw(body)` | The decoded response for a body sent verbatim |
157
+ | `buildBody(measures, options?)` | The request body, without sending it |
158
+
159
+ ```ts
160
+ const result = await incorta.data.query(
161
+ [{ field: "HR_BS.Employee_BS.SALARY", aggregation: "sum", label: "payroll" }],
162
+ {
163
+ rows: ["HR_BS.Employee_BS.JOB_TITLE"],
164
+ aggregate: true,
165
+ filters: [
166
+ {
167
+ type: "fieldKey",
168
+ fieldKey: "HR_BS.Employee_BS.JOB_TITLE",
169
+ op: "IN_LIST",
170
+ values: ["Accountant"],
171
+ },
172
+ ],
173
+ sorting: [{ field: "HR_BS.Employee_BS.JOB_TITLE", dir: "desc" }],
174
+ },
175
+ );
176
+ result.headers; // ["JOB_TITLE", "payroll"]
177
+ result.records(); // [{ JOB_TITLE: "Accountant", payroll: "39600.0" }]
178
+ result.totalRows; // rows matching beyond this page
179
+ ```
180
+
181
+ A bare string is shorthand for `{ field }` in both `measures` and the dimension
182
+ lists. Cells always come back as strings — Incorta renders every value as text.
183
+
184
+ `aggregate: false` gives a flat extract; `aggregate: true` folds each measure
185
+ with its `aggregation` and groups by `rows` and `columns`.
186
+
187
+ ### Models
188
+
189
+ Plain data — no classes — so results survive `structuredClone`,
190
+ `JSON.stringify`, and a trip through a worker or an HTTP response. Where Python
191
+ uses `isinstance`, these carry a `kind` discriminant that narrows in TypeScript
192
+ **and** survives serialisation:
193
+
194
+ ```ts
195
+ const schema = await incorta.schemas.get("OnlineStore");
196
+ if (schema.kind === "physical") {
197
+ for (const table of schema.tables) console.log(table.rowsCount);
198
+ } else {
199
+ for (const view of schema.views) console.log(view.sources);
200
+ }
201
+ ```
202
+
203
+ Every model keeps the untouched API record in `.raw`, so a field this package
204
+ does not model is still reachable.
205
+
206
+ ### Errors
207
+
208
+ Everything derives from `IncortaError`:
209
+
210
+ ```text
211
+ IncortaError
212
+ ├── IncortaConfigError a setting is missing or malformed
213
+ ├── IncortaAuthRequiredError no signed-in user on this request
214
+ ├── IncortaSessionExpiredError the captured token aged out (thrown locally)
215
+ ├── IncortaConnectionError environment unreachable
216
+ │ └── IncortaTimeoutError
217
+ ├── IncortaApiError non-2xx, carrying .statusCode and .code
218
+ │ ├── AuthenticationError 401 — Incorta refused the token
219
+ │ ├── PermissionDeniedError 403 — this user lacks access
220
+ │ ├── NotFoundError 404
221
+ │ │ └── SchemaNotFoundError
222
+ │ └── IncortaServerError 5xx
223
+ └── TableNotFoundError detected client-side, lists what does exist
224
+ ```
225
+
226
+ ## Token lifetime
227
+
228
+ `forRequest` refreshes the access token as it reads the session, so a client
229
+ built per request always starts fresh. A scoped client held past its token's
230
+ expiry throws `IncortaSessionExpiredError` **before** making a request, rather
231
+ than letting Incorta answer 401 — build one per request and the case never
232
+ arises.
233
+
234
+ ## Behaviour both SDKs share
235
+
236
+ These are the API quirks the SDKs exist to absorb, handled identically in
237
+ TypeScript and Python.
238
+
239
+ - **`schemaType` fails silently.** `?schemaType=TYPO` returns HTTP 200 with
240
+ *business* schemas, and so does omitting the parameter. A typo would hand you
241
+ plausible but wrong data, so both clients validate the value locally and
242
+ always send it explicitly.
243
+ - **Physical and business schemas return disjoint keys.** A physical schema
244
+ carries `tablesDetails`; a business schema carries `viewsDetails`. The other
245
+ key is absent entirely, not empty. Both clients return a different type for
246
+ each rather than one half-null shape.
247
+ - **The API misspells its own value** as `BUSSINESS_VIEW` (three S's). Both
248
+ clients round-trip that spelling and accept the corrected one, so nothing
249
+ breaks whichever way Incorta resolves it.
250
+ - **There is no per-table endpoint.** Fetching one table means fetching its
251
+ whole schema, so prefer `schemas.get(name)` once over N `tables.get` calls.
252
+ - **`aggregate` defaults to *true* when omitted.** A flat extract written
253
+ without it returns **zero rows with HTTP 200**, reporting string columns as
254
+ `double`. Both clients always send the flag explicitly.
255
+ - **Aggregate queries ignore the top-level `sorting` list.** Sorting is read
256
+ only from inside a dimension. Both clients route each sort onto the dimension
257
+ it names, and reject a sort matching none rather than letting it vanish.
258
+ - **`format: "csv"` cannot be unstringified.** Asking for both returns the
259
+ header line alone, with HTTP 200. Both clients pick the encoding themselves.
260
+ - **`nullValueAs: "DASH"` is documented but rejected** with HTTP 400. Both
261
+ clients omit it from the accepted values and say why.
262
+ - **The query endpoint uses a different error envelope**, `{"errorMessages":
263
+ [{"message": "INC_..."}]}`, and answers some 400s in plain text rather than
264
+ JSON. Both clients parse all three shapes onto the same error object.
265
+ - **Errors carry a stable `INC_` code** inside `{"message": "INC_09030108: ..."}`.
266
+ Both clients parse it onto the error object separately from the prose.
267
+ - **Tokens never appear** in logs, serialised output, or a client's public
268
+ surface.
269
+
270
+ ## Development
271
+
272
+ From the repository root (a pnpm workspace — this package resolves
273
+ `@incorta/auth` from `packages/auth` via `workspace:^`, so it always builds
274
+ against the auth code in this commit):
275
+
276
+ ```bash
277
+ pnpm install
278
+ pnpm build && pnpm typecheck && pnpm test
279
+ ```
280
+
281
+ Released off the same `v{version}` tag as `@incorta/auth`, at the same version;
282
+ the publish pipeline rewrites the `workspace:^` dependency to that exact
283
+ version. See the [root README](../../README.md#releases).