measurr 0.3.0 → 0.5.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 +175 -28
- package/dist/analytics.d.ts +47 -4
- package/dist/dataset.d.ts +50 -30
- package/dist/handler.d.ts +56 -0
- package/dist/handler.js +49 -0
- package/dist/index-g255jb1s.js +149 -0
- package/dist/index-g2mjqx4f.js +303 -0
- package/dist/{index-wrr67sxq.js → index-zgb1e89d.js} +160 -451
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -5
- package/dist/period.d.ts +5 -0
- package/dist/shapes.d.ts +111 -0
- package/dist/shapes.js +134 -0
- package/dist/testing.d.ts +9 -4
- package/dist/testing.js +13 -3
- package/package.json +14 -2
- package/src/analytics.ts +337 -40
- package/src/dataset.ts +135 -60
- package/src/handler.ts +124 -0
- package/src/index.ts +3 -0
- package/src/period.ts +32 -0
- package/src/plan.ts +6 -0
- package/src/shapes.ts +295 -0
- package/src/testing.ts +31 -9
package/README.md
CHANGED
|
@@ -12,10 +12,12 @@ returns shaped, typed rows.
|
|
|
12
12
|
- Tenant-scoped by construction: the engine adds the tenant filter to every statement and
|
|
13
13
|
refuses to run without a tenant. A caller can be scoped to one tenant or a list of them;
|
|
14
14
|
platform admins can query every tenant only through an explicit `allTenants` value that no
|
|
15
|
-
query input can carry.
|
|
15
|
+
query input can carry. A dataset without tenants (staff work) is only for those admins.
|
|
16
|
+
- Access per dataset and per field: a staff-only measure lives on the same dataset as the
|
|
17
|
+
measures everyone may use.
|
|
16
18
|
- The query schema is generated with Zod, so it doubles as an LLM tool schema
|
|
17
19
|
(`z.toJSONSchema`) and as the request validator. It can be built per caller, offering only
|
|
18
|
-
the datasets they may
|
|
20
|
+
the datasets and fields they may use.
|
|
19
21
|
- No driver or ORM dependency. `zod` is a peer dependency.
|
|
20
22
|
|
|
21
23
|
## Quick start
|
|
@@ -51,6 +53,11 @@ export const orders = defineDataset({
|
|
|
51
53
|
measures: {
|
|
52
54
|
orders: measure.count({ label: 'Orders' }),
|
|
53
55
|
revenue: measure.sum(order.col('amount'), { label: 'Revenue' }),
|
|
56
|
+
// Only callers `authorizeField` allows for 'staff' see or query it.
|
|
57
|
+
margin: measure.sum(order.col('amount').sub(order.col('cost')), {
|
|
58
|
+
label: 'Margin',
|
|
59
|
+
access: 'staff'
|
|
60
|
+
}),
|
|
54
61
|
refunded: measure.countWhere(exists(refund, eq(refund.col('orderId'), order.col('id'))), {
|
|
55
62
|
label: 'Refunded orders'
|
|
56
63
|
}),
|
|
@@ -96,6 +103,7 @@ export const analytics = createAnalytics({
|
|
|
96
103
|
},
|
|
97
104
|
tenant: (ctx: Ctx) => ctx.organizationId,
|
|
98
105
|
authorize: (dataset, ctx) => ctx.can(`analytics:${dataset.key}`),
|
|
106
|
+
authorizeField: (dataset, key, access, ctx) => ctx.roles.includes(access),
|
|
99
107
|
timezone: (ctx) => ctx.timezone,
|
|
100
108
|
onQuery: (event, ctx) => metrics.record(event.dataset, event.durationMs, event.outcome)
|
|
101
109
|
})
|
|
@@ -117,18 +125,38 @@ result.rows[0]?.paidShare // Ratio | null
|
|
|
117
125
|
|
|
118
126
|
## Callers and tools
|
|
119
127
|
|
|
120
|
-
|
|
128
|
+
Three rules decide what a caller may use, and everything that offers datasets goes through
|
|
129
|
+
them:
|
|
130
|
+
|
|
131
|
+
- `authorize(dataset, ctx)` decides which datasets `ctx` may query (all when omitted).
|
|
132
|
+
- `authorizeField(dataset, key, access, ctx)` decides which marked measures and dimensions it
|
|
133
|
+
may use. A field is marked with `access`, a string your app defines (`'staff'`); unmarked
|
|
134
|
+
fields are open to everyone who may query the dataset. The hook is called only for marked
|
|
135
|
+
fields, once per access key, and is required as soon as one field is marked
|
|
136
|
+
(`createAnalytics` throws otherwise). A `ratio` also needs the access of the measures it
|
|
137
|
+
divides, so a margin rate is as restricted as the margin. The hook's `access` is typed as the
|
|
138
|
+
union of the keys the datasets use (`AccessKey`) and `key` as their field keys (`FieldKey`),
|
|
139
|
+
so a new key such as `'finance'` fails an exhaustive `switch` in the hook until it is handled.
|
|
140
|
+
- A dataset without a tenant column is only for a `ctx` whose `tenant(ctx)` is `allTenants`
|
|
141
|
+
(see below).
|
|
142
|
+
|
|
143
|
+
With them:
|
|
121
144
|
|
|
122
145
|
- `analytics.listDatasets(ctx)` resolves to the catalog entries (measures, dimensions, closed
|
|
123
|
-
values) of the datasets `ctx` may query.
|
|
124
|
-
|
|
125
|
-
|
|
146
|
+
values) of the datasets `ctx` may query, without the fields it may not use. A dataset with
|
|
147
|
+
no measure left for `ctx` is left out.
|
|
148
|
+
- `analytics.querySchema({ ctx })` resolves to the input schema for the same datasets and
|
|
149
|
+
fields, for a tool built per caller, so a model is never offered what it may not use. It
|
|
150
|
+
rejects with `AnalyticsError('forbidden')` when nothing is allowed.
|
|
126
151
|
`analytics.querySchema({ datasets })` is the synchronous form for a list you already have
|
|
127
|
-
(typed for those datasets only), and `querySchema()` covers every dataset. Schemas
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
152
|
+
(typed for those datasets only), and `querySchema()` covers every dataset and field. Schemas
|
|
153
|
+
are built once per set of datasets and fields, so `querySchema({ ctx })` returns the same
|
|
154
|
+
object as `querySchema({ datasets })` when `ctx` may use every field of the allowed set:
|
|
155
|
+
cache a tool built from it by the keys of what it offers.
|
|
156
|
+
- `analytics.query` checks all three again on every call, so a schema offered too widely still
|
|
157
|
+
cannot leak a dataset or a field. Naming a field `ctx` may not use (as a measure, in
|
|
158
|
+
`groupBy` or `sort`, or in a filter) fails with `AnalyticsError('forbidden')`, which names
|
|
159
|
+
the field.
|
|
132
160
|
|
|
133
161
|
`AnalyticsResultSchema` is any dataset's result without its keys: rows of `{ key, label }`
|
|
134
162
|
per grouped dimension and a number or null per measure, with totals, `previous?`, `notes` and
|
|
@@ -148,9 +176,95 @@ ${analyticsToolGuidance}`
|
|
|
148
176
|
```
|
|
149
177
|
|
|
150
178
|
`onQuery(event, ctx)` hears every `query` once it succeeds or fails:
|
|
151
|
-
`{ dataset, durationMs, rows, outcome: 'success' | 'error', error? }`. `dataset` is null
|
|
152
|
-
the input named no known dataset, so free text from a model never becomes a metric label.
|
|
153
|
-
What the hook throws or rejects with is
|
|
179
|
+
`{ dataset, durationMs, rows, outcome: 'success' | 'error', cache?, error? }`. `dataset` is null
|
|
180
|
+
when the input named no known dataset, so free text from a model never becomes a metric label.
|
|
181
|
+
`cache` is `'hit'` or `'miss'` when a cache is configured. What the hook throws or rejects with is
|
|
182
|
+
swallowed; it cannot fail a query.
|
|
183
|
+
|
|
184
|
+
Each measure in `listDatasets` has a `valueType`: `count` (a whole number, never null),
|
|
185
|
+
`ratio` (0-1, from `share` and `ratio`) or `number` (sums, averages, medians), so a UI knows
|
|
186
|
+
to format a share as a percentage.
|
|
187
|
+
|
|
188
|
+
## Caching
|
|
189
|
+
|
|
190
|
+
`cache: { get, set }` keeps finished results in a store of your choice, such as Redis. A query
|
|
191
|
+
is parsed and authorized first, so a cached result never reaches a caller who may not run the
|
|
192
|
+
query. The key is `measurr:<dataset>:<sha256>`, hashed from the query with its defaults, the
|
|
193
|
+
tenant scope, the timezone and the resolved days (so `last 30 days` moves on at local midnight).
|
|
194
|
+
Results are plain JSON.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
const TTL_SECONDS: Record<AnalyticsDatasetKey, number> = { orders: 300, units: 60 }
|
|
198
|
+
|
|
199
|
+
createAnalytics({
|
|
200
|
+
// ...
|
|
201
|
+
cache: {
|
|
202
|
+
// A miss bypasses the cache; the fresh result is still stored.
|
|
203
|
+
get: async (key, { ctx }) =>
|
|
204
|
+
ctx.refresh ? null : JSON.parse((await redis.get(`${BUILD_ID}:${key}`)) ?? 'null'),
|
|
205
|
+
set: (key, result, { dataset }) =>
|
|
206
|
+
redis.setex(`${BUILD_ID}:${key}`, TTL_SECONDS[dataset], JSON.stringify(result))
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
A stored value that is not a result of its dataset counts as a miss, and a cache that throws or
|
|
212
|
+
rejects is a miss or a skipped write, never a failed query. A changed measure keeps its key, so
|
|
213
|
+
prefix keys with a build id (as above) if old numbers must not outlive a deploy. A dataset that
|
|
214
|
+
uses `minutesAgo` is as stale as the time to live.
|
|
215
|
+
|
|
216
|
+
## Chart shapes
|
|
217
|
+
|
|
218
|
+
`measurr/shapes` turns a result into what a chart or a KPI card draws. The functions are pure
|
|
219
|
+
and add no numbers the result does not hold; with `compareToPrevious` they carry the previous
|
|
220
|
+
period too.
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import { toDistribution, toKpi, toPivot, toTimeseries, toLeaderboard } from 'measurr/shapes'
|
|
224
|
+
|
|
225
|
+
toKpi(result, 'orders')
|
|
226
|
+
// { kind: 'kpi', value: 12, previous: 8, change: 0.5 }
|
|
227
|
+
|
|
228
|
+
toTimeseries(result, { bucket: 'day', unit: 'day', measure: 'orders', series: 'status' })
|
|
229
|
+
// { kind: 'timeseries', unit, series: [{ key, label, points: [{ bucket: '2026-10-01', value }], previous? }] }
|
|
230
|
+
|
|
231
|
+
toDistribution(result, { by: 'status', measure: 'orders' }) // segments with share of the total
|
|
232
|
+
toPivot(result, { rows: 'weekday', columns: 'hour', measure: 'orders' }) // values[row][column]
|
|
233
|
+
toLeaderboard(result, { by: 'region', measure: 'revenue' }) // ranked rows with every measure
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
- `toTimeseries` gives every bucket of the period a point; buckets without rows get `fill` (0 by
|
|
237
|
+
default, pass `null` for averages). An all-time result has only the buckets it found. The
|
|
238
|
+
previous period's points are on its own buckets; compare them by position.
|
|
239
|
+
- A grouped query returns at most `limit` rows (20 by default, 100 at most), and the shape keeps
|
|
240
|
+
`truncated`. Set `limit: 100` for a series; 30 days split into three series fit, 90 days into
|
|
241
|
+
eleven do not.
|
|
242
|
+
- `toDistribution`'s `total` is the measure over every row, so many-to-many shares can add up to
|
|
243
|
+
more than 1. `toPivot` sorts numeric keys (weekdays, hours).
|
|
244
|
+
|
|
245
|
+
## Serving queries
|
|
246
|
+
|
|
247
|
+
`measurr/handler` serves queries from any server framework:
|
|
248
|
+
|
|
249
|
+
- `queryBatch(analytics, { total: q1, byStatus: q2 }, ctx)` runs named queries in parallel and
|
|
250
|
+
resolves to `{ results, errors }`, typed per name. One failing query does not fail the
|
|
251
|
+
others; an error other than `AnalyticsError` is reported as `{ code: 'internal' }` without
|
|
252
|
+
its message.
|
|
253
|
+
- `streamBatch(analytics, queries, ctx)` yields `{ key, result }` or `{ key, error }` as each
|
|
254
|
+
query finishes, for a streaming endpoint.
|
|
255
|
+
- `toHttpError(error)` maps an `AnalyticsError` to `{ status, code, message, data? }`:
|
|
256
|
+
`forbidden` is 403 `FORBIDDEN`, `missing_tenant` 401 `UNAUTHORIZED`, the rest 400
|
|
257
|
+
`BAD_REQUEST` with the code and details. The codes are the names oRPC and tRPC use:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
try {
|
|
261
|
+
return await analytics.query(input, ctx)
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (!(error instanceof AnalyticsError)) throw error
|
|
264
|
+
const { code, message, data } = toHttpError(error)
|
|
265
|
+
throw new ORPCError(code, { message, data })
|
|
266
|
+
}
|
|
267
|
+
```
|
|
154
268
|
|
|
155
269
|
## Tenants and platform admins
|
|
156
270
|
|
|
@@ -196,13 +310,33 @@ checking the session) before building `ctx`; never read it from the query input.
|
|
|
196
310
|
lookups resolve labels across tenants, so `"Complaint"` matches every tenant's tag of that
|
|
197
311
|
name.
|
|
198
312
|
|
|
313
|
+
**Datasets without tenants.** Some tables belong to no organization, such as maintenance
|
|
314
|
+
cases staff work on. Declare them with `tenantColumn: null`. The key stays required, so a
|
|
315
|
+
forgotten tenant column still fails typecheck.
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
export const maintenance = defineDataset({
|
|
319
|
+
key: 'maintenance',
|
|
320
|
+
label: 'Maintenance',
|
|
321
|
+
description: 'One row per maintenance case.',
|
|
322
|
+
from: maintenanceCase,
|
|
323
|
+
tenantColumn: null,
|
|
324
|
+
// ...
|
|
325
|
+
})
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Only a `ctx` whose `tenant(ctx)` is `allTenants` may query such a dataset, label lookups
|
|
329
|
+
included. An id or a list of ids, even an empty one, fails with `AnalyticsError('forbidden')`
|
|
330
|
+
before any statement runs; a narrower scope is never ignored. `listDatasets(ctx)` and
|
|
331
|
+
`querySchema({ ctx })` leave the dataset out for those callers.
|
|
332
|
+
|
|
199
333
|
## The model
|
|
200
334
|
|
|
201
335
|
| Concept | What it is |
|
|
202
336
|
| --- | --- |
|
|
203
|
-
| Dataset | One fact table: tenant column, optional scope, time fields, default period, measures, dimensions, source. |
|
|
204
|
-
| Measure | `count`, `countWhere`, `countDistinct`, `sum`, `avg`, `median`, `share` (one condition over another) or `ratio` (one measure over another, after aggregation). |
|
|
205
|
-
| Dimension | `enum`, `computed` (closed value sets), `relation` (to-one), `manyToMany`, `number`, `timeBucket` (day, ISO week, month), `timePart` (ISO weekday, hour). |
|
|
337
|
+
| Dataset | One fact table: tenant column (or `null`), optional scope, time fields, default period, measures, dimensions, source. |
|
|
338
|
+
| Measure | `count`, `countWhere`, `countDistinct`, `sum`, `avg`, `median`, `share` (one condition over another) or `ratio` (one measure over another, after aggregation). Optionally restricted with `access`. |
|
|
339
|
+
| Dimension | `enum`, `computed` (closed value sets), `relation` (to-one), `manyToMany`, `number`, `timeBucket` (day, ISO week, month), `timePart` (ISO weekday, hour). Optionally restricted with `access`. |
|
|
206
340
|
| Query | `dataset`, 1-4 `measures`, 0-2 `groupBy`, `filters`, `period`, `time`, `compareToPrevious`, `sort` (by an asked measure or grouped dimension), `limit` (1-100, default 20). |
|
|
207
341
|
| Result | `{ period, rows, totals, previous?, notes, truncated }`; `period` is `{ from, to, timezone }` or `{ all: true, timezone }`. |
|
|
208
342
|
|
|
@@ -344,12 +478,22 @@ It checks that every measure runs and returns a number or null, every grouping r
|
|
|
344
478
|
groups add up to the total (except many-to-many), `isEmpty` plus `isNotEmpty` equals the total,
|
|
345
479
|
open dimensions filter by label to their group's count, closed dimensions filter by value, an
|
|
346
480
|
empty tenant and an empty tenant list see nothing (tenant isolation), and day buckets in a
|
|
347
|
-
far-from-UTC timezone (default `Pacific/Auckland`) agree with single-day periods.
|
|
481
|
+
far-from-UTC timezone (default `Pacific/Auckland`) agree with single-day periods. Fields marked
|
|
482
|
+
with `access` are checked like any other.
|
|
483
|
+
|
|
484
|
+
A dataset without a tenant column has no tenant rows to isolate. Pass `tenant: allTenants`,
|
|
485
|
+
and the kit replaces the isolation checks with one that an empty tenant and an empty tenant
|
|
486
|
+
list are refused with `forbidden`. Every other check runs as usual.
|
|
487
|
+
|
|
488
|
+
```ts
|
|
489
|
+
await assertDatasetContract({ dataset: maintenance, source, tenant: allTenants })
|
|
490
|
+
```
|
|
348
491
|
|
|
349
492
|
## Extending
|
|
350
493
|
|
|
351
494
|
- **A measure or dimension** is one entry in a dataset. Dimensions are groupable and
|
|
352
|
-
filterable unless `groupOnly` or `filterOnly` says otherwise
|
|
495
|
+
filterable unless `groupOnly` or `filterOnly` says otherwise, and every field is open to
|
|
496
|
+
everyone who may query the dataset unless `access` restricts it. The contract kit covers it
|
|
353
497
|
without a new test.
|
|
354
498
|
- **A dataset** is one `defineDataset` call added to `createAnalytics({ datasets })`.
|
|
355
499
|
- **A computed value** is written with the expression builders (`caseWhen`, `exists`,
|
|
@@ -362,15 +506,18 @@ far-from-UTC timezone (default `Pacific/Auckland`) agree with single-day periods
|
|
|
362
506
|
|
|
363
507
|
## Stable API
|
|
364
508
|
|
|
365
|
-
Kept stable for publishing: `createAnalytics` and its options
|
|
366
|
-
`
|
|
367
|
-
`
|
|
368
|
-
`
|
|
369
|
-
`
|
|
370
|
-
`
|
|
371
|
-
`
|
|
372
|
-
|
|
373
|
-
|
|
509
|
+
Kept stable for publishing: `createAnalytics` and its options (with `authorize`,
|
|
510
|
+
`authorizeField` and `cache`), `allTenants`, `analytics.query`, `explain`, `querySchema`,
|
|
511
|
+
`resultSchema`, `listDatasets`; `AnalyticsResultSchema`, `analyticsToolGuidance`,
|
|
512
|
+
`AnalyticsQueryEvent`; `defineDataset` (with `defaultPeriod` and `tenantColumn: null`),
|
|
513
|
+
`measure.*` and `dimension.*` (with `access`), `table` / `Table`, the expression builders; the
|
|
514
|
+
`Dialect`, `SqlRenderer` and `AnalyticsSource` contracts; `postgresDialect`,
|
|
515
|
+
`checkDatasetContract` / `assertDatasetContract`; `measurr/shapes` (`toKpi`, `toTimeseries`,
|
|
516
|
+
`toDistribution`, `toPivot`, `toLeaderboard`) and `measurr/handler` (`queryBatch`,
|
|
517
|
+
`streamBatch`, `toHttpError`); and the types `AnalyticsQuery`, `AnalyticsResult`,
|
|
518
|
+
`ResultPeriod`, `PeriodInput`, `Tenant`, `TenantId`, `AccessKey`, `AnalyticsCache`, `FieldKey`,
|
|
519
|
+
`Ratio`, `AnalyticsError` codes and their details (`isAnalyticsError`). `ExprNode` and
|
|
520
|
+
`SelectStatement` are exported for dialect authors and may grow new node kinds.
|
|
374
521
|
|
|
375
522
|
## Install
|
|
376
523
|
|
package/dist/analytics.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { CompiledStatement, Dialect } from './compile.js';
|
|
3
|
-
import type { AnyDataset, AnyDimension } from './dataset.js';
|
|
3
|
+
import type { AnyDataset, AnyDimension, Dataset } from './dataset.js';
|
|
4
4
|
import { type Tenant } from './plan.js';
|
|
5
5
|
import { type DatasetQuery, type QueryResult, type ResultOf } from './query.js';
|
|
6
6
|
export type Row = Record<string, unknown>;
|
|
@@ -50,9 +50,15 @@ export type AnalyticsQueryEvent = {
|
|
|
50
50
|
/** Rows returned; 0 on error. */
|
|
51
51
|
rows: number;
|
|
52
52
|
outcome: 'success' | 'error';
|
|
53
|
+
/** Whether `cache` answered the query; absent without a cache or when it never got that far. */
|
|
54
|
+
cache?: 'hit' | 'miss';
|
|
53
55
|
/** What the query threw, on error. */
|
|
54
56
|
error?: unknown;
|
|
55
57
|
};
|
|
58
|
+
/** The measure and dimension keys of a dataset (of any dataset, for a union). */
|
|
59
|
+
export type FieldKey<D> = D extends Dataset<string, infer M, infer Dm> ? keyof (M & Dm) & string : never;
|
|
60
|
+
/** The access keys a dataset's measures and dimensions are marked with. */
|
|
61
|
+
export type AccessKey<D> = D extends Dataset<string, infer M, infer Dm> ? NonNullable<(M & Dm)[keyof (M & Dm)]['access']> : never;
|
|
56
62
|
export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
|
|
57
63
|
datasets: DS;
|
|
58
64
|
/** Databases by name; a dataset's `source` picks one, or the only one is used. */
|
|
@@ -69,6 +75,14 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
|
|
|
69
75
|
* `listDatasets(ctx)` and `querySchema({ ctx })` offer, and is checked again on every query.
|
|
70
76
|
*/
|
|
71
77
|
authorize?: ((dataset: DS[number], ctx: Ctx) => boolean | Promise<boolean>) | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* Whether `ctx` may use the measure or dimension `key` of `dataset`, which is marked with
|
|
80
|
+
* `access`. Called only for marked fields, once per access key: a ratio also needs the
|
|
81
|
+
* access of the measures it divides. Required when any field is marked. It decides which
|
|
82
|
+
* fields `listDatasets(ctx)` and `querySchema({ ctx })` offer, and is checked again on every
|
|
83
|
+
* query. `access` is the union of the keys the datasets use, so a new key shows up here.
|
|
84
|
+
*/
|
|
85
|
+
authorizeField?: ((dataset: DS[number], key: FieldKey<DS[number]>, access: AccessKey<DS[number]>, ctx: Ctx) => boolean | Promise<boolean>) | undefined;
|
|
72
86
|
/** IANA timezone periods and time buckets resolve in. UTC when omitted. */
|
|
73
87
|
timezone?: ((ctx: Ctx) => string | null | undefined | Promise<string | null | undefined>) | undefined;
|
|
74
88
|
/** The clock periods and `minutesAgo` resolve against. */
|
|
@@ -78,6 +92,33 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
|
|
|
78
92
|
* throws or rejects with is swallowed, so it can never fail a query.
|
|
79
93
|
*/
|
|
80
94
|
onQuery?: ((event: AnalyticsQueryEvent, ctx: Ctx) => unknown) | undefined;
|
|
95
|
+
/**
|
|
96
|
+
* Where finished results are kept and looked up. What it throws or rejects with is
|
|
97
|
+
* swallowed: a failing cache is a miss, never a failed query.
|
|
98
|
+
*/
|
|
99
|
+
cache?: AnalyticsCache<Ctx, DS[number]['key']> | undefined;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* A store for finished results, such as Redis. Keys name the dataset and hash everything that
|
|
103
|
+
* decides the result: the query with its defaults, the tenant scope, the timezone and the
|
|
104
|
+
* resolved days. A query is parsed and authorized before the cache is asked, so a cached result
|
|
105
|
+
* never reaches a caller who may not run the query. Results are plain JSON.
|
|
106
|
+
*/
|
|
107
|
+
export type AnalyticsCache<Ctx, Key extends string = string> = {
|
|
108
|
+
/**
|
|
109
|
+
* The stored result, or null/undefined for a miss. Return a miss to bypass the cache for a
|
|
110
|
+
* caller (a refresh button); its fresh result is still stored. A stored value that is not a
|
|
111
|
+
* result of this dataset counts as a miss, so a changed definition does not break queries.
|
|
112
|
+
*/
|
|
113
|
+
get(key: string, entry: {
|
|
114
|
+
dataset: Key;
|
|
115
|
+
ctx: Ctx;
|
|
116
|
+
}): unknown;
|
|
117
|
+
/** Stores a result; pick the time to live per `dataset`. */
|
|
118
|
+
set(key: string, result: QueryResult<Row, Row>, entry: {
|
|
119
|
+
dataset: Key;
|
|
120
|
+
ctx: Ctx;
|
|
121
|
+
}): unknown;
|
|
81
122
|
};
|
|
82
123
|
export type DatasetCatalogEntry<Key extends string = string> = {
|
|
83
124
|
key: Key;
|
|
@@ -91,6 +132,8 @@ export type DatasetCatalogEntry<Key extends string = string> = {
|
|
|
91
132
|
key: string;
|
|
92
133
|
label: string;
|
|
93
134
|
description: string | undefined;
|
|
135
|
+
/** `count`: a whole number, never null. `ratio`: 0-1 (`share`, `ratio`). `number`: the rest. */
|
|
136
|
+
valueType: 'count' | 'number' | 'ratio';
|
|
94
137
|
}[];
|
|
95
138
|
dimensions: {
|
|
96
139
|
key: string;
|
|
@@ -123,15 +166,15 @@ export type Analytics<DS extends readonly AnyDataset[], Ctx> = {
|
|
|
123
166
|
key: K;
|
|
124
167
|
}>>>;
|
|
125
168
|
/**
|
|
126
|
-
* The input schema for the datasets `
|
|
127
|
-
* Rejects with `forbidden` when none are allowed.
|
|
169
|
+
* The input schema for the datasets `ctx` may query, without the fields `authorizeField`
|
|
170
|
+
* refuses it, for a tool built per caller. Rejects with `forbidden` when none are allowed.
|
|
128
171
|
*/
|
|
129
172
|
querySchema(options: {
|
|
130
173
|
ctx: Ctx;
|
|
131
174
|
}): Promise<z.ZodType<DatasetQuery<DS[number]>>>;
|
|
132
175
|
/** The result schema for one dataset. */
|
|
133
176
|
resultSchema(dataset: DS[number]['key']): z.ZodType<QueryResult<Row, Row>>;
|
|
134
|
-
/** The datasets `
|
|
177
|
+
/** The datasets `ctx` may query, with the measures and dimensions it may use. */
|
|
135
178
|
listDatasets(ctx: Ctx): Promise<DatasetCatalogEntry<DS[number]['key']>[]>;
|
|
136
179
|
};
|
|
137
180
|
export type AnalyticsQuery<A> = A extends Analytics<infer DS, never> ? DatasetQuery<DS[number]> : never;
|
package/dist/dataset.d.ts
CHANGED
|
@@ -5,9 +5,19 @@ declare const ratioBrand: unique symbol;
|
|
|
5
5
|
export type Ratio = number & {
|
|
6
6
|
readonly [ratioBrand]: 'zeroToOne';
|
|
7
7
|
};
|
|
8
|
-
type Described = {
|
|
8
|
+
type Described<Access extends string = string> = {
|
|
9
9
|
label: string;
|
|
10
10
|
description?: string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Restricts the field to callers `authorizeField` allows for this access key (a string the
|
|
13
|
+
* app defines, such as `'staff'`). Unmarked fields are open to everyone who may query the
|
|
14
|
+
* dataset.
|
|
15
|
+
*/
|
|
16
|
+
access?: Access | undefined;
|
|
17
|
+
};
|
|
18
|
+
/** Keeps a field's access key literal, so `authorizeField` is typed with the keys in use. */
|
|
19
|
+
export type Marked<Access extends string> = {
|
|
20
|
+
readonly access: Access | undefined;
|
|
11
21
|
};
|
|
12
22
|
/**
|
|
13
23
|
* `count` results are never null. `number` results (sum, avg, median) are null for an empty
|
|
@@ -19,6 +29,7 @@ export type AggregateMeasure<R extends 'count' | 'number' = 'count' | 'number'>
|
|
|
19
29
|
readonly result: R;
|
|
20
30
|
readonly label: string;
|
|
21
31
|
readonly description: string | undefined;
|
|
32
|
+
readonly access: string | undefined;
|
|
22
33
|
readonly node: Extract<ExprNode, {
|
|
23
34
|
kind: 'aggregate';
|
|
24
35
|
}>;
|
|
@@ -28,6 +39,7 @@ export type ShareMeasure = {
|
|
|
28
39
|
readonly result: 'ratio';
|
|
29
40
|
readonly label: string;
|
|
30
41
|
readonly description: string | undefined;
|
|
42
|
+
readonly access: string | undefined;
|
|
31
43
|
readonly numerator: ExprNode;
|
|
32
44
|
readonly denominator: ExprNode | null;
|
|
33
45
|
};
|
|
@@ -36,6 +48,8 @@ export type RatioMeasure<A extends string = string, B extends string = string> =
|
|
|
36
48
|
readonly result: 'ratio';
|
|
37
49
|
readonly label: string;
|
|
38
50
|
readonly description: string | undefined;
|
|
51
|
+
/** A ratio also needs the access of the measures it divides. */
|
|
52
|
+
readonly access: string | undefined;
|
|
39
53
|
readonly numerator: A;
|
|
40
54
|
readonly denominator: B;
|
|
41
55
|
};
|
|
@@ -46,32 +60,32 @@ export type MeasureValue<M> = M extends {
|
|
|
46
60
|
} ? number : M extends {
|
|
47
61
|
result: 'number';
|
|
48
62
|
} ? number | null : Ratio | null;
|
|
49
|
-
type Filtered = Described & {
|
|
63
|
+
type Filtered<Access extends string> = Described<Access> & {
|
|
50
64
|
where?: Expr<'boolean'> | undefined;
|
|
51
65
|
};
|
|
52
66
|
export declare const measure: {
|
|
53
67
|
/** Number of rows, optionally only those matching `where`. */
|
|
54
|
-
count(options: Filtered): AggregateMeasure<'count'
|
|
68
|
+
count<Access extends string = never>(options: Filtered<Access>): AggregateMeasure<'count'> & Marked<NoInfer<Access>>;
|
|
55
69
|
/** Number of rows matching `condition`. */
|
|
56
|
-
countWhere(condition: Expr<'boolean'>, options: Described): AggregateMeasure<'count'
|
|
70
|
+
countWhere<Access extends string = never>(condition: Expr<'boolean'>, options: Described<Access>): AggregateMeasure<'count'> & Marked<NoInfer<Access>>;
|
|
57
71
|
/** Number of distinct non-null values of `expr`. */
|
|
58
|
-
countDistinct(expr: Expr<SqlType>, options: Filtered): AggregateMeasure<'count'
|
|
59
|
-
sum(expr: Expr<'number'>, options: Filtered): AggregateMeasure<'number'
|
|
60
|
-
avg(expr: Expr<'number'>, options: Filtered): AggregateMeasure<'number'
|
|
61
|
-
median(expr: Expr<'number'>, options: Filtered): AggregateMeasure<'number'
|
|
72
|
+
countDistinct<Access extends string = never>(expr: Expr<SqlType>, options: Filtered<Access>): AggregateMeasure<'count'> & Marked<NoInfer<Access>>;
|
|
73
|
+
sum<Access extends string = never>(expr: Expr<'number'>, options: Filtered<Access>): AggregateMeasure<'number'> & Marked<NoInfer<Access>>;
|
|
74
|
+
avg<Access extends string = never>(expr: Expr<'number'>, options: Filtered<Access>): AggregateMeasure<'number'> & Marked<NoInfer<Access>>;
|
|
75
|
+
median<Access extends string = never>(expr: Expr<'number'>, options: Filtered<Access>): AggregateMeasure<'number'> & Marked<NoInfer<Access>>;
|
|
62
76
|
/**
|
|
63
77
|
* The share of rows matching `numerator` among rows matching `denominator` (all rows when
|
|
64
78
|
* omitted). A `Ratio` from 0 to 1.
|
|
65
79
|
*/
|
|
66
|
-
share(options: Described & {
|
|
80
|
+
share<Access extends string = never>(options: Described<Access> & {
|
|
67
81
|
numerator: Expr<'boolean'>;
|
|
68
82
|
denominator?: Expr<'boolean'> | undefined;
|
|
69
|
-
}): ShareMeasure
|
|
83
|
+
}): ShareMeasure & Marked<NoInfer<Access>>;
|
|
70
84
|
/**
|
|
71
85
|
* One measure of this dataset over another, computed after aggregation. Both keys must be
|
|
72
86
|
* measures of the same dataset; `defineDataset` checks them.
|
|
73
87
|
*/
|
|
74
|
-
ratio<const A extends string, const B extends string>(numerator: A, denominator: B, options: Described): RatioMeasure<A, B
|
|
88
|
+
ratio<const A extends string, const B extends string, Access extends string = never>(numerator: A, denominator: B, options: Described<Access>): RatioMeasure<A, B> & Marked<NoInfer<Access>>;
|
|
75
89
|
};
|
|
76
90
|
/**
|
|
77
91
|
* How a dimension filters:
|
|
@@ -82,22 +96,22 @@ export declare const measure: {
|
|
|
82
96
|
* - `time`: time buckets keyed by ISO date - `between`, `isEmpty`, `isNotEmpty`.
|
|
83
97
|
*/
|
|
84
98
|
export type DimensionCategory = 'closed' | 'open' | 'numeric' | 'time';
|
|
85
|
-
type DimensionOptions<G extends boolean = boolean, F extends boolean = boolean> = Described & {
|
|
99
|
+
type DimensionOptions<G extends boolean = boolean, F extends boolean = boolean, Access extends string = string> = Described<Access> & {
|
|
86
100
|
/** Only offered for grouping. */
|
|
87
101
|
groupOnly?: G | undefined;
|
|
88
102
|
/** Only offered for filtering. */
|
|
89
103
|
filterOnly?: F | undefined;
|
|
90
104
|
};
|
|
91
105
|
/** Time dimensions name themselves after their unit unless given a label. */
|
|
92
|
-
type TimeDimensionOptions<G extends boolean, F extends boolean, Key> = Omit<DimensionOptions<G, F>, 'label'> & {
|
|
106
|
+
type TimeDimensionOptions<G extends boolean, F extends boolean, Key, Access extends string> = Omit<DimensionOptions<G, F, Access>, 'label'> & {
|
|
93
107
|
label?: string | undefined;
|
|
94
108
|
labelFor?: ((key: Key) => string) | undefined;
|
|
95
109
|
};
|
|
96
110
|
/**
|
|
97
111
|
* `groupOnly: true` makes `filterable` the literal `false` (and `filterOnly`, `groupable`), so
|
|
98
|
-
* queries cannot filter or group by what the dimension does not offer.
|
|
112
|
+
* queries cannot filter or group by what the dimension does not offer. `access` stays literal.
|
|
99
113
|
*/
|
|
100
|
-
export type DimensionAccess<G extends boolean, F extends boolean> = {
|
|
114
|
+
export type DimensionAccess<G extends boolean, F extends boolean, Access extends string = never> = Marked<Access> & {
|
|
101
115
|
readonly groupable: [F] extends [true] ? false : boolean;
|
|
102
116
|
readonly filterable: [G] extends [true] ? false : boolean;
|
|
103
117
|
};
|
|
@@ -105,6 +119,7 @@ type DimensionBase<C extends DimensionCategory, Key> = {
|
|
|
105
119
|
readonly category: C;
|
|
106
120
|
readonly label: string;
|
|
107
121
|
readonly description: string | undefined;
|
|
122
|
+
readonly access: string | undefined;
|
|
108
123
|
readonly groupable: boolean;
|
|
109
124
|
readonly filterable: boolean;
|
|
110
125
|
/** Phantom: the key type in result rows. */
|
|
@@ -187,29 +202,29 @@ export declare const dimension: {
|
|
|
187
202
|
* every value, so adding an enum value fails typecheck until it has a label, and a column
|
|
188
203
|
* typed with values outside `values` fails too. A nullable column gives null keys.
|
|
189
204
|
*/
|
|
190
|
-
enum<const E extends Record<string, string>, V extends string | null, G extends boolean = boolean, F extends boolean = boolean>(expr: Expr<'string', V> & CoversValues<V, E[keyof E]>, values: E, options: DimensionOptions<G, F> & EmptyOption & {
|
|
205
|
+
enum<const E extends Record<string, string>, V extends string | null, G extends boolean = boolean, F extends boolean = boolean, Access extends string = never>(expr: Expr<'string', V> & CoversValues<V, E[keyof E]>, values: E, options: DimensionOptions<G, F, Access> & EmptyOption & {
|
|
191
206
|
labels: Record<E[keyof E], string>;
|
|
192
|
-
}): EnumDimension<E[keyof E], EmptyOf<V>> & DimensionAccess<G, F
|
|
207
|
+
}): EnumDimension<E[keyof E], EmptyOf<V>> & DimensionAccess<G, F, NoInfer<Access>>;
|
|
193
208
|
/**
|
|
194
209
|
* A value computed from other columns with the expression builders, usually `caseWhen`.
|
|
195
210
|
* `values` lists every value the expression can produce, with its label; a plain value of
|
|
196
211
|
* the expression missing from `values` fails typecheck. A `caseWhen` without `otherwise`
|
|
197
212
|
* gives null keys.
|
|
198
213
|
*/
|
|
199
|
-
computed<V extends string | null, const K extends string, G extends boolean = boolean, F extends boolean = boolean>(options: DimensionOptions<G, F> & EmptyOption & {
|
|
214
|
+
computed<V extends string | null, const K extends string, G extends boolean = boolean, F extends boolean = boolean, Access extends string = never>(options: DimensionOptions<G, F, Access> & EmptyOption & {
|
|
200
215
|
expr: Expr<'string', V> & CoversValues<V, NoInfer<K>>;
|
|
201
216
|
values: Record<K, string>;
|
|
202
|
-
}): EnumDimension<K, EmptyOf<V>> & DimensionAccess<G, F
|
|
217
|
+
}): EnumDimension<K, EmptyOf<V>> & DimensionAccess<G, F, NoInfer<Access>>;
|
|
203
218
|
/**
|
|
204
219
|
* A to-one relation such as a team. The key type comes from the `key` column, so a branded
|
|
205
220
|
* id column types the key in filters and rows. Rows without a related record get the
|
|
206
221
|
* `empty` label and a null key.
|
|
207
222
|
*/
|
|
208
|
-
relation<Key extends string | number, G extends boolean = boolean, F extends boolean = boolean>(options: DimensionOptions<G, F> & {
|
|
223
|
+
relation<Key extends string | number, G extends boolean = boolean, F extends boolean = boolean, Access extends string = never>(options: DimensionOptions<G, F, Access> & {
|
|
209
224
|
key: Expr<Key extends number ? 'number' : 'string', Key | null>;
|
|
210
225
|
name: Expr<'string'>;
|
|
211
226
|
empty?: string | undefined;
|
|
212
|
-
}): RelationDimension<Key> & DimensionAccess<G, F
|
|
227
|
+
}): RelationDimension<Key> & DimensionAccess<G, F, NoInfer<Access>>;
|
|
213
228
|
/**
|
|
214
229
|
* A to-many relation through a link table, such as tags. Grouping counts a row once per
|
|
215
230
|
* related value, so totals for such a grouping come from a separate query. Filtering
|
|
@@ -218,7 +233,7 @@ export declare const dimension: {
|
|
|
218
233
|
* `through.table` is the link table as a standalone `table()` (further joins hang off it),
|
|
219
234
|
* and `through.on` links it to the dataset's table.
|
|
220
235
|
*/
|
|
221
|
-
manyToMany<Row = unknown, G extends boolean = boolean, F extends boolean = boolean>(options: DimensionOptions<G, F> & {
|
|
236
|
+
manyToMany<Row = unknown, G extends boolean = boolean, F extends boolean = boolean, Access extends string = never>(options: DimensionOptions<G, F, Access> & {
|
|
222
237
|
through: {
|
|
223
238
|
table: Table<Row>;
|
|
224
239
|
on: Expr<'boolean'>;
|
|
@@ -228,15 +243,15 @@ export declare const dimension: {
|
|
|
228
243
|
/** Explains the overlap to the reader of a grouped result. */
|
|
229
244
|
note?: string | undefined;
|
|
230
245
|
empty?: string | undefined;
|
|
231
|
-
}): ManyToManyDimension & DimensionAccess<G, F
|
|
246
|
+
}): ManyToManyDimension & DimensionAccess<G, F, NoInfer<Access>>;
|
|
232
247
|
/** A numeric column, grouped by its exact values and filtered with `between`. */
|
|
233
|
-
number<G extends boolean = boolean, F extends boolean = boolean>(expr: Expr<'number'>, options: DimensionOptions<G, F> & {
|
|
248
|
+
number<G extends boolean = boolean, F extends boolean = boolean, Access extends string = never>(expr: Expr<'number'>, options: DimensionOptions<G, F, Access> & {
|
|
234
249
|
labelFor?: ((key: number) => string) | undefined;
|
|
235
|
-
}): NumberDimension & DimensionAccess<G, F
|
|
250
|
+
}): NumberDimension & DimensionAccess<G, F, NoInfer<Access>>;
|
|
236
251
|
/** Day, ISO week (starting Monday) or month of a time field, in the tenant's timezone. */
|
|
237
|
-
timeBucket<const T extends string, G extends boolean = boolean, F extends boolean = boolean>(time: T, unit: TimeBucketUnit, options?: TimeDimensionOptions<G, F, string>): TimeBucketDimension<T> & DimensionAccess<G, F
|
|
252
|
+
timeBucket<const T extends string, G extends boolean = boolean, F extends boolean = boolean, Access extends string = never>(time: T, unit: TimeBucketUnit, options?: TimeDimensionOptions<G, F, string, Access>): TimeBucketDimension<T> & DimensionAccess<G, F, NoInfer<Access>>;
|
|
238
253
|
/** ISO weekday (1 = Monday) or hour (0-23) of a time field, in the tenant's timezone. */
|
|
239
|
-
timePart<const T extends string, G extends boolean = boolean, F extends boolean = boolean>(time: T, part: TimePartName, options?: TimeDimensionOptions<G, F, number>): TimePartDimension<T> & DimensionAccess<G, F
|
|
254
|
+
timePart<const T extends string, G extends boolean = boolean, F extends boolean = boolean, Access extends string = never>(time: T, part: TimePartName, options?: TimeDimensionOptions<G, F, number, Access>): TimePartDimension<T> & DimensionAccess<G, F, NoInfer<Access>>;
|
|
240
255
|
};
|
|
241
256
|
export type TimeField = {
|
|
242
257
|
readonly column: ExprNode;
|
|
@@ -248,7 +263,8 @@ export type Dataset<K extends string = string, M extends Record<string, AnyMeasu
|
|
|
248
263
|
readonly description: string;
|
|
249
264
|
readonly from: TableRef;
|
|
250
265
|
readonly source: string | undefined;
|
|
251
|
-
|
|
266
|
+
/** Null for a dataset without tenants, which only `allTenants` may query. */
|
|
267
|
+
readonly tenantColumn: ExprNode | null;
|
|
252
268
|
readonly scope: ExprNode | null;
|
|
253
269
|
readonly time: Readonly<Record<T, TimeField>>;
|
|
254
270
|
readonly defaultTime: T;
|
|
@@ -285,8 +301,12 @@ export type DatasetDefinition<K extends string, M extends Record<string, AnyMeas
|
|
|
285
301
|
};
|
|
286
302
|
/** Which source (database) the dataset runs on; the default when there is one source. */
|
|
287
303
|
source?: string | undefined;
|
|
288
|
-
/**
|
|
289
|
-
|
|
304
|
+
/**
|
|
305
|
+
* The column every query is filtered by; the value comes from `tenant(ctx)`. `null` for a
|
|
306
|
+
* dataset whose rows belong to no tenant (staff work, platform-wide records): only a caller
|
|
307
|
+
* whose `tenant(ctx)` is `allTenants` may query it, and everyone else is refused.
|
|
308
|
+
*/
|
|
309
|
+
tenantColumn: Expr<SqlType> | null;
|
|
290
310
|
/** Always applied, for example excluding test rows. */
|
|
291
311
|
scope?: Expr<'boolean'> | undefined;
|
|
292
312
|
time: Record<T, {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serving queries from any server framework: several queries in one request (all at once or
|
|
3
|
+
* as each finishes), and engine errors as HTTP errors. Framework-neutral; an oRPC or tRPC
|
|
4
|
+
* procedure wraps these in a few lines.
|
|
5
|
+
*/
|
|
6
|
+
import { type Analytics, AnalyticsError, type AnalyticsErrorCode, type AnalyticsErrorDetails } from './analytics.js';
|
|
7
|
+
import type { AnyDataset } from './dataset.js';
|
|
8
|
+
import type { DatasetQuery, ResultOf } from './query.js';
|
|
9
|
+
/** Why one query of a batch failed. Errors other than `AnalyticsError` are not described. */
|
|
10
|
+
export type BatchError = {
|
|
11
|
+
code: AnalyticsErrorCode | 'internal';
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
14
|
+
export type BatchResult<DS extends readonly AnyDataset[], Q> = {
|
|
15
|
+
results: {
|
|
16
|
+
[K in keyof Q]?: ResultOf<DS[number], Q[K]>;
|
|
17
|
+
};
|
|
18
|
+
errors: {
|
|
19
|
+
[K in keyof Q]?: BatchError;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export type BatchChunk<DS extends readonly AnyDataset[], Q> = {
|
|
23
|
+
[K in keyof Q & string]: {
|
|
24
|
+
key: K;
|
|
25
|
+
result: ResultOf<DS[number], Q[K]>;
|
|
26
|
+
} | {
|
|
27
|
+
key: K;
|
|
28
|
+
error: BatchError;
|
|
29
|
+
};
|
|
30
|
+
}[keyof Q & string];
|
|
31
|
+
type Queries<DS extends readonly AnyDataset[]> = Record<string, DatasetQuery<DS[number]>>;
|
|
32
|
+
/**
|
|
33
|
+
* Runs named queries in parallel. One query failing does not fail the others: its error is
|
|
34
|
+
* under its name. Each query is authorized, cached and reported like a single `query`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function queryBatch<DS extends readonly AnyDataset[], Ctx, const Q extends Queries<DS>>(analytics: Analytics<DS, Ctx>, queries: Q, ctx: Ctx): Promise<BatchResult<DS, Q>>;
|
|
37
|
+
/** Runs named queries in parallel and yields each as it finishes, for a streaming endpoint. */
|
|
38
|
+
export declare function streamBatch<DS extends readonly AnyDataset[], Ctx, const Q extends Queries<DS>>(analytics: Analytics<DS, Ctx>, queries: Q, ctx: Ctx): AsyncGenerator<BatchChunk<DS, Q>, void, undefined>;
|
|
39
|
+
export type HttpError = {
|
|
40
|
+
status: 400 | 401 | 403;
|
|
41
|
+
/** The status name oRPC and tRPC use for it. */
|
|
42
|
+
code: 'BAD_REQUEST' | 'UNAUTHORIZED' | 'FORBIDDEN';
|
|
43
|
+
message: string;
|
|
44
|
+
/** For a bad request: the engine's code and details, so a model can fix its query. */
|
|
45
|
+
data?: {
|
|
46
|
+
code: AnalyticsErrorCode;
|
|
47
|
+
details: AnalyticsErrorDetails[AnalyticsErrorCode];
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* An engine error as an HTTP error: `forbidden` is 403, `missing_tenant` 401, the rest are
|
|
52
|
+
* 400 with the code and details. Messages are safe to show; for `unknown_value` they list the
|
|
53
|
+
* valid values.
|
|
54
|
+
*/
|
|
55
|
+
export declare function toHttpError(error: AnalyticsError): HttpError;
|
|
56
|
+
export {};
|