measurr 0.2.0 → 0.4.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 +118 -31
- package/dist/analytics.d.ts +20 -7
- package/dist/dataset.d.ts +61 -33
- package/dist/guidance.d.ts +1 -1
- package/dist/{index-dj3369zz.js → index-4rmjas9h.js} +165 -44
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/period.d.ts +12 -3
- package/dist/plan.d.ts +6 -4
- package/dist/query.d.ts +40 -30
- package/dist/testing.d.ts +9 -4
- package/dist/testing.js +35 -22
- package/package.json +1 -1
- package/src/analytics.ts +230 -46
- package/src/dataset.ts +153 -65
- package/src/guidance.ts +1 -1
- package/src/index.ts +5 -2
- package/src/period.ts +10 -4
- package/src/plan.ts +40 -21
- package/src/query.ts +75 -26
- package/src/testing.ts +59 -33
package/README.md
CHANGED
|
@@ -10,11 +10,14 @@ returns shaped, typed rows.
|
|
|
10
10
|
- Database-neutral definitions: dialects plug in through the `Dialect` interface, and Postgres
|
|
11
11
|
ships today (`measurr/postgres`).
|
|
12
12
|
- Tenant-scoped by construction: the engine adds the tenant filter to every statement and
|
|
13
|
-
refuses to run without a tenant.
|
|
14
|
-
explicit `allTenants` value that no
|
|
13
|
+
refuses to run without a tenant. A caller can be scoped to one tenant or a list of them;
|
|
14
|
+
platform admins can query every tenant only through an explicit `allTenants` value that no
|
|
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.
|
|
15
18
|
- The query schema is generated with Zod, so it doubles as an LLM tool schema
|
|
16
19
|
(`z.toJSONSchema`) and as the request validator. It can be built per caller, offering only
|
|
17
|
-
the datasets they may
|
|
20
|
+
the datasets and fields they may use.
|
|
18
21
|
- No driver or ORM dependency. `zod` is a peer dependency.
|
|
19
22
|
|
|
20
23
|
## Quick start
|
|
@@ -50,6 +53,11 @@ export const orders = defineDataset({
|
|
|
50
53
|
measures: {
|
|
51
54
|
orders: measure.count({ label: 'Orders' }),
|
|
52
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
|
+
}),
|
|
53
61
|
refunded: measure.countWhere(exists(refund, eq(refund.col('orderId'), order.col('id'))), {
|
|
54
62
|
label: 'Refunded orders'
|
|
55
63
|
}),
|
|
@@ -95,6 +103,7 @@ export const analytics = createAnalytics({
|
|
|
95
103
|
},
|
|
96
104
|
tenant: (ctx: Ctx) => ctx.organizationId,
|
|
97
105
|
authorize: (dataset, ctx) => ctx.can(`analytics:${dataset.key}`),
|
|
106
|
+
authorizeField: (dataset, key, access, ctx) => ctx.roles.includes(access),
|
|
98
107
|
timezone: (ctx) => ctx.timezone,
|
|
99
108
|
onQuery: (event, ctx) => metrics.record(event.dataset, event.durationMs, event.outcome)
|
|
100
109
|
})
|
|
@@ -116,18 +125,38 @@ result.rows[0]?.paidShare // Ratio | null
|
|
|
116
125
|
|
|
117
126
|
## Callers and tools
|
|
118
127
|
|
|
119
|
-
|
|
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:
|
|
120
144
|
|
|
121
145
|
- `analytics.listDatasets(ctx)` resolves to the catalog entries (measures, dimensions, closed
|
|
122
|
-
values) of the datasets `ctx` may query.
|
|
123
|
-
|
|
124
|
-
|
|
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.
|
|
125
151
|
`analytics.querySchema({ datasets })` is the synchronous form for a list you already have
|
|
126
|
-
(typed for those datasets only), and `querySchema()` covers every dataset. Schemas
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
131
160
|
|
|
132
161
|
`AnalyticsResultSchema` is any dataset's result without its keys: rows of `{ key, label }`
|
|
133
162
|
per grouped dimension and a number or null per measure, with totals, `previous?`, `notes` and
|
|
@@ -154,9 +183,21 @@ What the hook throws or rejects with is swallowed; it cannot fail a query.
|
|
|
154
183
|
## Tenants and platform admins
|
|
155
184
|
|
|
156
185
|
`tenant(ctx)` decides the scope of every statement, including the lookups that resolve filter
|
|
157
|
-
labels. It returns a tenant id
|
|
158
|
-
|
|
159
|
-
`AnalyticsError('missing_tenant')
|
|
186
|
+
labels. It returns a tenant id (`TenantId`, a non-empty string or a finite number), a readonly
|
|
187
|
+
list of them, or `allTenants` to drop the tenant filter for a caller allowed to see every
|
|
188
|
+
tenant. Anything else is refused with `AnalyticsError('missing_tenant')`: null, undefined,
|
|
189
|
+
`''`, a list containing one of those or `allTenants`, or any other value. A missing
|
|
190
|
+
organization never widens a query.
|
|
191
|
+
|
|
192
|
+
A list compiles to `tenantColumn IN (...)` with one parameter per id; a one-element list is
|
|
193
|
+
the same statement as its id. An empty list is a scope that matches no rows: a reseller with
|
|
194
|
+
no connected organizations sees zeros, never everything. Lists are for callers scoped to a set
|
|
195
|
+
of organizations, such as an organization-group admin, a reseller, or either narrowed to a
|
|
196
|
+
region:
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
tenant: (ctx: Ctx) => ctx.visibleOrganizationIds // readonly OrganizationId[]
|
|
200
|
+
```
|
|
160
201
|
|
|
161
202
|
The scope comes only from `ctx`, which your server builds from the session. `allTenants` is a
|
|
162
203
|
symbol, so a query (JSON, a model's tool call) cannot carry it. A discriminated context keeps
|
|
@@ -183,15 +224,35 @@ checking the session) before building `ctx`; never read it from the query input.
|
|
|
183
224
|
lookups resolve labels across tenants, so `"Complaint"` matches every tenant's tag of that
|
|
184
225
|
name.
|
|
185
226
|
|
|
227
|
+
**Datasets without tenants.** Some tables belong to no organization, such as maintenance
|
|
228
|
+
cases staff work on. Declare them with `tenantColumn: null`. The key stays required, so a
|
|
229
|
+
forgotten tenant column still fails typecheck.
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
export const maintenance = defineDataset({
|
|
233
|
+
key: 'maintenance',
|
|
234
|
+
label: 'Maintenance',
|
|
235
|
+
description: 'One row per maintenance case.',
|
|
236
|
+
from: maintenanceCase,
|
|
237
|
+
tenantColumn: null,
|
|
238
|
+
// ...
|
|
239
|
+
})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Only a `ctx` whose `tenant(ctx)` is `allTenants` may query such a dataset, label lookups
|
|
243
|
+
included. An id or a list of ids, even an empty one, fails with `AnalyticsError('forbidden')`
|
|
244
|
+
before any statement runs; a narrower scope is never ignored. `listDatasets(ctx)` and
|
|
245
|
+
`querySchema({ ctx })` leave the dataset out for those callers.
|
|
246
|
+
|
|
186
247
|
## The model
|
|
187
248
|
|
|
188
249
|
| Concept | What it is |
|
|
189
250
|
| --- | --- |
|
|
190
|
-
| Dataset | One fact table: tenant column, optional scope, time fields, measures, dimensions, source. |
|
|
191
|
-
| Measure | `count`, `countWhere`, `countDistinct`, `sum`, `avg`, `median`, `share` (one condition over another) or `ratio` (one measure over another, after aggregation). |
|
|
192
|
-
| Dimension | `enum`, `computed` (closed value sets), `relation` (to-one), `manyToMany`, `number`, `timeBucket` (day, ISO week, month), `timePart` (ISO weekday, hour). |
|
|
251
|
+
| Dataset | One fact table: tenant column (or `null`), optional scope, time fields, default period, measures, dimensions, source. |
|
|
252
|
+
| Measure | `count`, `countWhere`, `countDistinct`, `sum`, `avg`, `median`, `share` (one condition over another) or `ratio` (one measure over another, after aggregation). Optionally restricted with `access`. |
|
|
253
|
+
| Dimension | `enum`, `computed` (closed value sets), `relation` (to-one), `manyToMany`, `number`, `timeBucket` (day, ISO week, month), `timePart` (ISO weekday, hour). Optionally restricted with `access`. |
|
|
193
254
|
| 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). |
|
|
194
|
-
| Result | `{ period, rows, totals, previous?, notes, truncated }`. |
|
|
255
|
+
| Result | `{ period, rows, totals, previous?, notes, truncated }`; `period` is `{ from, to, timezone }` or `{ all: true, timezone }`. |
|
|
195
256
|
|
|
196
257
|
**Operators per dimension kind.** Closed and open dimensions take `in`, `notIn`, `isEmpty`
|
|
197
258
|
and `isNotEmpty`; numeric and time dimensions take `between`, `isEmpty` and `isNotEmpty`. The
|
|
@@ -222,6 +283,21 @@ try {
|
|
|
222
283
|
The previous period has the same length and ends the day before. Time buckets use the same
|
|
223
284
|
timezone. `time` picks the time field the period applies to; a time dimension names its own.
|
|
224
285
|
|
|
286
|
+
`{ all: true }` drops the time filter: every row counts, and time buckets and parts still group
|
|
287
|
+
by the time field. It has no previous period, so `compareToPrevious` with it is an
|
|
288
|
+
`invalid_query`. The result's `period` is then `{ all: true, timezone }` instead of
|
|
289
|
+
`{ from, to, timezone }`; `previous.period` is always a range. The type follows the query. A
|
|
290
|
+
query that names a range, or names no period on a dataset whose default is a range, gets
|
|
291
|
+
`{ from, to, timezone }`, so `result.period.from` needs no narrowing. A query typed only as
|
|
292
|
+
`AnalyticsQuery` (parsed from the schema) gets the `ResultPeriod` union; narrow it with
|
|
293
|
+
`'all' in result.period`.
|
|
294
|
+
|
|
295
|
+
A query that names no period (or sends null) gets the dataset's `defaultPeriod`, the last 30
|
|
296
|
+
days unless the dataset says otherwise. A dataset that describes current state (units,
|
|
297
|
+
organizations) sets `defaultPeriod: { all: true }`, so "how many units per status" counts
|
|
298
|
+
every unit. The generated schema states each dataset's default, so a model knows what leaving
|
|
299
|
+
`period` out means.
|
|
300
|
+
|
|
225
301
|
**Totals are a separate, ungrouped statement**, so a many-to-many grouping (an order with
|
|
226
302
|
two tags appears in two rows) never inflates them. Filters on many-to-many dimensions compile
|
|
227
303
|
to `EXISTS`, so they never multiply rows either.
|
|
@@ -315,13 +391,23 @@ await assertDatasetContract({ dataset: orders, source, tenant: seededTenantId })
|
|
|
315
391
|
It checks that every measure runs and returns a number or null, every grouping runs and its
|
|
316
392
|
groups add up to the total (except many-to-many), `isEmpty` plus `isNotEmpty` equals the total,
|
|
317
393
|
open dimensions filter by label to their group's count, closed dimensions filter by value, an
|
|
318
|
-
empty tenant
|
|
319
|
-
(default `Pacific/Auckland`) agree with single-day periods.
|
|
394
|
+
empty tenant and an empty tenant list see nothing (tenant isolation), and day buckets in a
|
|
395
|
+
far-from-UTC timezone (default `Pacific/Auckland`) agree with single-day periods. Fields marked
|
|
396
|
+
with `access` are checked like any other.
|
|
397
|
+
|
|
398
|
+
A dataset without a tenant column has no tenant rows to isolate. Pass `tenant: allTenants`,
|
|
399
|
+
and the kit replaces the isolation checks with one that an empty tenant and an empty tenant
|
|
400
|
+
list are refused with `forbidden`. Every other check runs as usual.
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
await assertDatasetContract({ dataset: maintenance, source, tenant: allTenants })
|
|
404
|
+
```
|
|
320
405
|
|
|
321
406
|
## Extending
|
|
322
407
|
|
|
323
408
|
- **A measure or dimension** is one entry in a dataset. Dimensions are groupable and
|
|
324
|
-
filterable unless `groupOnly` or `filterOnly` says otherwise
|
|
409
|
+
filterable unless `groupOnly` or `filterOnly` says otherwise, and every field is open to
|
|
410
|
+
everyone who may query the dataset unless `access` restricts it. The contract kit covers it
|
|
325
411
|
without a new test.
|
|
326
412
|
- **A dataset** is one `defineDataset` call added to `createAnalytics({ datasets })`.
|
|
327
413
|
- **A computed value** is written with the expression builders (`caseWhen`, `exists`,
|
|
@@ -334,15 +420,16 @@ empty tenant sees nothing (tenant isolation), and day buckets in a far-from-UTC
|
|
|
334
420
|
|
|
335
421
|
## Stable API
|
|
336
422
|
|
|
337
|
-
Kept stable for publishing: `createAnalytics` and its options
|
|
338
|
-
`
|
|
339
|
-
`
|
|
340
|
-
`
|
|
341
|
-
`
|
|
342
|
-
`
|
|
343
|
-
`AnalyticsQuery`, `AnalyticsResult`, `
|
|
344
|
-
|
|
345
|
-
|
|
423
|
+
Kept stable for publishing: `createAnalytics` and its options (with `authorize` and
|
|
424
|
+
`authorizeField`), `allTenants`, `analytics.query`, `explain`, `querySchema`, `resultSchema`,
|
|
425
|
+
`listDatasets`; `AnalyticsResultSchema`, `analyticsToolGuidance`, `AnalyticsQueryEvent`;
|
|
426
|
+
`defineDataset` (with `defaultPeriod` and `tenantColumn: null`), `measure.*` and `dimension.*`
|
|
427
|
+
(with `access`), `table` / `Table`, the expression builders; the `Dialect`,
|
|
428
|
+
`SqlRenderer` and `AnalyticsSource` contracts; `postgresDialect`, `checkDatasetContract` /
|
|
429
|
+
`assertDatasetContract`; and the types `AnalyticsQuery`, `AnalyticsResult`, `ResultPeriod`,
|
|
430
|
+
`PeriodInput`, `Tenant`, `TenantId`, `AccessKey`, `FieldKey`, `Ratio`, `AnalyticsError` codes
|
|
431
|
+
and their details (`isAnalyticsError`). `ExprNode` and `SelectStatement` are exported for
|
|
432
|
+
dialect authors and may grow new node kinds.
|
|
346
433
|
|
|
347
434
|
## Install
|
|
348
435
|
|
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>;
|
|
@@ -53,14 +53,19 @@ export type AnalyticsQueryEvent = {
|
|
|
53
53
|
/** What the query threw, on error. */
|
|
54
54
|
error?: unknown;
|
|
55
55
|
};
|
|
56
|
+
/** The measure and dimension keys of a dataset (of any dataset, for a union). */
|
|
57
|
+
export type FieldKey<D> = D extends Dataset<string, infer M, infer Dm> ? keyof (M & Dm) & string : never;
|
|
58
|
+
/** The access keys a dataset's measures and dimensions are marked with. */
|
|
59
|
+
export type AccessKey<D> = D extends Dataset<string, infer M, infer Dm> ? NonNullable<(M & Dm)[keyof (M & Dm)]['access']> : never;
|
|
56
60
|
export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
|
|
57
61
|
datasets: DS;
|
|
58
62
|
/** Databases by name; a dataset's `source` picks one, or the only one is used. */
|
|
59
63
|
sources: Record<string, AnalyticsSource>;
|
|
60
64
|
/**
|
|
61
|
-
* The tenant every query is scoped to
|
|
62
|
-
*
|
|
63
|
-
*
|
|
65
|
+
* The tenant every query is scoped to, or a list of them (an empty list matches no rows).
|
|
66
|
+
* Queries without one (null, undefined, '', a list holding one of those) are refused.
|
|
67
|
+
* Return `allTenants` to drop the tenant filter, only for callers allowed to see every
|
|
68
|
+
* tenant's rows.
|
|
64
69
|
*/
|
|
65
70
|
tenant: (ctx: Ctx) => Tenant | null | undefined;
|
|
66
71
|
/**
|
|
@@ -68,6 +73,14 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
|
|
|
68
73
|
* `listDatasets(ctx)` and `querySchema({ ctx })` offer, and is checked again on every query.
|
|
69
74
|
*/
|
|
70
75
|
authorize?: ((dataset: DS[number], ctx: Ctx) => boolean | Promise<boolean>) | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Whether `ctx` may use the measure or dimension `key` of `dataset`, which is marked with
|
|
78
|
+
* `access`. Called only for marked fields, once per access key: a ratio also needs the
|
|
79
|
+
* access of the measures it divides. Required when any field is marked. It decides which
|
|
80
|
+
* fields `listDatasets(ctx)` and `querySchema({ ctx })` offer, and is checked again on every
|
|
81
|
+
* query. `access` is the union of the keys the datasets use, so a new key shows up here.
|
|
82
|
+
*/
|
|
83
|
+
authorizeField?: ((dataset: DS[number], key: FieldKey<DS[number]>, access: AccessKey<DS[number]>, ctx: Ctx) => boolean | Promise<boolean>) | undefined;
|
|
71
84
|
/** IANA timezone periods and time buckets resolve in. UTC when omitted. */
|
|
72
85
|
timezone?: ((ctx: Ctx) => string | null | undefined | Promise<string | null | undefined>) | undefined;
|
|
73
86
|
/** The clock periods and `minutesAgo` resolve against. */
|
|
@@ -122,15 +135,15 @@ export type Analytics<DS extends readonly AnyDataset[], Ctx> = {
|
|
|
122
135
|
key: K;
|
|
123
136
|
}>>>;
|
|
124
137
|
/**
|
|
125
|
-
* The input schema for the datasets `
|
|
126
|
-
* Rejects with `forbidden` when none are allowed.
|
|
138
|
+
* The input schema for the datasets `ctx` may query, without the fields `authorizeField`
|
|
139
|
+
* refuses it, for a tool built per caller. Rejects with `forbidden` when none are allowed.
|
|
127
140
|
*/
|
|
128
141
|
querySchema(options: {
|
|
129
142
|
ctx: Ctx;
|
|
130
143
|
}): Promise<z.ZodType<DatasetQuery<DS[number]>>>;
|
|
131
144
|
/** The result schema for one dataset. */
|
|
132
145
|
resultSchema(dataset: DS[number]['key']): z.ZodType<QueryResult<Row, Row>>;
|
|
133
|
-
/** The datasets `
|
|
146
|
+
/** The datasets `ctx` may query, with the measures and dimensions it may use. */
|
|
134
147
|
listDatasets(ctx: Ctx): Promise<DatasetCatalogEntry<DS[number]['key']>[]>;
|
|
135
148
|
};
|
|
136
149
|
export type AnalyticsQuery<A> = A extends Analytics<infer DS, never> ? DatasetQuery<DS[number]> : never;
|
package/dist/dataset.d.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import { type Expr, type ExprNode, type SqlType, type Table, type TableRef, type TimeBucketUnit, type TimePartName } from './expr.js';
|
|
2
|
+
import { DEFAULT_PERIOD, type PeriodInput } from './period.js';
|
|
2
3
|
declare const ratioBrand: unique symbol;
|
|
3
4
|
/** A share or ratio between 0 and 1. Branded so nobody renders 0.42 as "0.42%". */
|
|
4
5
|
export type Ratio = number & {
|
|
5
6
|
readonly [ratioBrand]: 'zeroToOne';
|
|
6
7
|
};
|
|
7
|
-
type Described = {
|
|
8
|
+
type Described<Access extends string = string> = {
|
|
8
9
|
label: string;
|
|
9
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;
|
|
10
21
|
};
|
|
11
22
|
/**
|
|
12
23
|
* `count` results are never null. `number` results (sum, avg, median) are null for an empty
|
|
@@ -18,6 +29,7 @@ export type AggregateMeasure<R extends 'count' | 'number' = 'count' | 'number'>
|
|
|
18
29
|
readonly result: R;
|
|
19
30
|
readonly label: string;
|
|
20
31
|
readonly description: string | undefined;
|
|
32
|
+
readonly access: string | undefined;
|
|
21
33
|
readonly node: Extract<ExprNode, {
|
|
22
34
|
kind: 'aggregate';
|
|
23
35
|
}>;
|
|
@@ -27,6 +39,7 @@ export type ShareMeasure = {
|
|
|
27
39
|
readonly result: 'ratio';
|
|
28
40
|
readonly label: string;
|
|
29
41
|
readonly description: string | undefined;
|
|
42
|
+
readonly access: string | undefined;
|
|
30
43
|
readonly numerator: ExprNode;
|
|
31
44
|
readonly denominator: ExprNode | null;
|
|
32
45
|
};
|
|
@@ -35,6 +48,8 @@ export type RatioMeasure<A extends string = string, B extends string = string> =
|
|
|
35
48
|
readonly result: 'ratio';
|
|
36
49
|
readonly label: string;
|
|
37
50
|
readonly description: string | undefined;
|
|
51
|
+
/** A ratio also needs the access of the measures it divides. */
|
|
52
|
+
readonly access: string | undefined;
|
|
38
53
|
readonly numerator: A;
|
|
39
54
|
readonly denominator: B;
|
|
40
55
|
};
|
|
@@ -45,32 +60,32 @@ export type MeasureValue<M> = M extends {
|
|
|
45
60
|
} ? number : M extends {
|
|
46
61
|
result: 'number';
|
|
47
62
|
} ? number | null : Ratio | null;
|
|
48
|
-
type Filtered = Described & {
|
|
63
|
+
type Filtered<Access extends string> = Described<Access> & {
|
|
49
64
|
where?: Expr<'boolean'> | undefined;
|
|
50
65
|
};
|
|
51
66
|
export declare const measure: {
|
|
52
67
|
/** Number of rows, optionally only those matching `where`. */
|
|
53
|
-
count(options: Filtered): AggregateMeasure<'count'
|
|
68
|
+
count<Access extends string = never>(options: Filtered<Access>): AggregateMeasure<'count'> & Marked<NoInfer<Access>>;
|
|
54
69
|
/** Number of rows matching `condition`. */
|
|
55
|
-
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>>;
|
|
56
71
|
/** Number of distinct non-null values of `expr`. */
|
|
57
|
-
countDistinct(expr: Expr<SqlType>, options: Filtered): AggregateMeasure<'count'
|
|
58
|
-
sum(expr: Expr<'number'>, options: Filtered): AggregateMeasure<'number'
|
|
59
|
-
avg(expr: Expr<'number'>, options: Filtered): AggregateMeasure<'number'
|
|
60
|
-
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>>;
|
|
61
76
|
/**
|
|
62
77
|
* The share of rows matching `numerator` among rows matching `denominator` (all rows when
|
|
63
78
|
* omitted). A `Ratio` from 0 to 1.
|
|
64
79
|
*/
|
|
65
|
-
share(options: Described & {
|
|
80
|
+
share<Access extends string = never>(options: Described<Access> & {
|
|
66
81
|
numerator: Expr<'boolean'>;
|
|
67
82
|
denominator?: Expr<'boolean'> | undefined;
|
|
68
|
-
}): ShareMeasure
|
|
83
|
+
}): ShareMeasure & Marked<NoInfer<Access>>;
|
|
69
84
|
/**
|
|
70
85
|
* One measure of this dataset over another, computed after aggregation. Both keys must be
|
|
71
86
|
* measures of the same dataset; `defineDataset` checks them.
|
|
72
87
|
*/
|
|
73
|
-
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>>;
|
|
74
89
|
};
|
|
75
90
|
/**
|
|
76
91
|
* How a dimension filters:
|
|
@@ -81,22 +96,22 @@ export declare const measure: {
|
|
|
81
96
|
* - `time`: time buckets keyed by ISO date - `between`, `isEmpty`, `isNotEmpty`.
|
|
82
97
|
*/
|
|
83
98
|
export type DimensionCategory = 'closed' | 'open' | 'numeric' | 'time';
|
|
84
|
-
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> & {
|
|
85
100
|
/** Only offered for grouping. */
|
|
86
101
|
groupOnly?: G | undefined;
|
|
87
102
|
/** Only offered for filtering. */
|
|
88
103
|
filterOnly?: F | undefined;
|
|
89
104
|
};
|
|
90
105
|
/** Time dimensions name themselves after their unit unless given a label. */
|
|
91
|
-
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'> & {
|
|
92
107
|
label?: string | undefined;
|
|
93
108
|
labelFor?: ((key: Key) => string) | undefined;
|
|
94
109
|
};
|
|
95
110
|
/**
|
|
96
111
|
* `groupOnly: true` makes `filterable` the literal `false` (and `filterOnly`, `groupable`), so
|
|
97
|
-
* 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.
|
|
98
113
|
*/
|
|
99
|
-
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> & {
|
|
100
115
|
readonly groupable: [F] extends [true] ? false : boolean;
|
|
101
116
|
readonly filterable: [G] extends [true] ? false : boolean;
|
|
102
117
|
};
|
|
@@ -104,6 +119,7 @@ type DimensionBase<C extends DimensionCategory, Key> = {
|
|
|
104
119
|
readonly category: C;
|
|
105
120
|
readonly label: string;
|
|
106
121
|
readonly description: string | undefined;
|
|
122
|
+
readonly access: string | undefined;
|
|
107
123
|
readonly groupable: boolean;
|
|
108
124
|
readonly filterable: boolean;
|
|
109
125
|
/** Phantom: the key type in result rows. */
|
|
@@ -186,29 +202,29 @@ export declare const dimension: {
|
|
|
186
202
|
* every value, so adding an enum value fails typecheck until it has a label, and a column
|
|
187
203
|
* typed with values outside `values` fails too. A nullable column gives null keys.
|
|
188
204
|
*/
|
|
189
|
-
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 & {
|
|
190
206
|
labels: Record<E[keyof E], string>;
|
|
191
|
-
}): EnumDimension<E[keyof E], EmptyOf<V>> & DimensionAccess<G, F
|
|
207
|
+
}): EnumDimension<E[keyof E], EmptyOf<V>> & DimensionAccess<G, F, NoInfer<Access>>;
|
|
192
208
|
/**
|
|
193
209
|
* A value computed from other columns with the expression builders, usually `caseWhen`.
|
|
194
210
|
* `values` lists every value the expression can produce, with its label; a plain value of
|
|
195
211
|
* the expression missing from `values` fails typecheck. A `caseWhen` without `otherwise`
|
|
196
212
|
* gives null keys.
|
|
197
213
|
*/
|
|
198
|
-
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 & {
|
|
199
215
|
expr: Expr<'string', V> & CoversValues<V, NoInfer<K>>;
|
|
200
216
|
values: Record<K, string>;
|
|
201
|
-
}): EnumDimension<K, EmptyOf<V>> & DimensionAccess<G, F
|
|
217
|
+
}): EnumDimension<K, EmptyOf<V>> & DimensionAccess<G, F, NoInfer<Access>>;
|
|
202
218
|
/**
|
|
203
219
|
* A to-one relation such as a team. The key type comes from the `key` column, so a branded
|
|
204
220
|
* id column types the key in filters and rows. Rows without a related record get the
|
|
205
221
|
* `empty` label and a null key.
|
|
206
222
|
*/
|
|
207
|
-
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> & {
|
|
208
224
|
key: Expr<Key extends number ? 'number' : 'string', Key | null>;
|
|
209
225
|
name: Expr<'string'>;
|
|
210
226
|
empty?: string | undefined;
|
|
211
|
-
}): RelationDimension<Key> & DimensionAccess<G, F
|
|
227
|
+
}): RelationDimension<Key> & DimensionAccess<G, F, NoInfer<Access>>;
|
|
212
228
|
/**
|
|
213
229
|
* A to-many relation through a link table, such as tags. Grouping counts a row once per
|
|
214
230
|
* related value, so totals for such a grouping come from a separate query. Filtering
|
|
@@ -217,7 +233,7 @@ export declare const dimension: {
|
|
|
217
233
|
* `through.table` is the link table as a standalone `table()` (further joins hang off it),
|
|
218
234
|
* and `through.on` links it to the dataset's table.
|
|
219
235
|
*/
|
|
220
|
-
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> & {
|
|
221
237
|
through: {
|
|
222
238
|
table: Table<Row>;
|
|
223
239
|
on: Expr<'boolean'>;
|
|
@@ -227,30 +243,33 @@ export declare const dimension: {
|
|
|
227
243
|
/** Explains the overlap to the reader of a grouped result. */
|
|
228
244
|
note?: string | undefined;
|
|
229
245
|
empty?: string | undefined;
|
|
230
|
-
}): ManyToManyDimension & DimensionAccess<G, F
|
|
246
|
+
}): ManyToManyDimension & DimensionAccess<G, F, NoInfer<Access>>;
|
|
231
247
|
/** A numeric column, grouped by its exact values and filtered with `between`. */
|
|
232
|
-
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> & {
|
|
233
249
|
labelFor?: ((key: number) => string) | undefined;
|
|
234
|
-
}): NumberDimension & DimensionAccess<G, F
|
|
250
|
+
}): NumberDimension & DimensionAccess<G, F, NoInfer<Access>>;
|
|
235
251
|
/** Day, ISO week (starting Monday) or month of a time field, in the tenant's timezone. */
|
|
236
|
-
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>>;
|
|
237
253
|
/** ISO weekday (1 = Monday) or hour (0-23) of a time field, in the tenant's timezone. */
|
|
238
|
-
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>>;
|
|
239
255
|
};
|
|
240
256
|
export type TimeField = {
|
|
241
257
|
readonly column: ExprNode;
|
|
242
258
|
readonly label: string;
|
|
243
259
|
};
|
|
244
|
-
export type Dataset<K extends string = string, M extends Record<string, AnyMeasure> = Record<string, AnyMeasure>, Dm extends Record<string, AnyDimension> = Record<string, AnyDimension>, T extends string = string> = {
|
|
260
|
+
export type Dataset<K extends string = string, M extends Record<string, AnyMeasure> = Record<string, AnyMeasure>, Dm extends Record<string, AnyDimension> = Record<string, AnyDimension>, T extends string = string, P extends PeriodInput = PeriodInput> = {
|
|
245
261
|
readonly key: K;
|
|
246
262
|
readonly label: string;
|
|
247
263
|
readonly description: string;
|
|
248
264
|
readonly from: TableRef;
|
|
249
265
|
readonly source: string | undefined;
|
|
250
|
-
|
|
266
|
+
/** Null for a dataset without tenants, which only `allTenants` may query. */
|
|
267
|
+
readonly tenantColumn: ExprNode | null;
|
|
251
268
|
readonly scope: ExprNode | null;
|
|
252
269
|
readonly time: Readonly<Record<T, TimeField>>;
|
|
253
270
|
readonly defaultTime: T;
|
|
271
|
+
/** Kept as a type, so results know whether a query without a period covers all time. */
|
|
272
|
+
readonly defaultPeriod: P;
|
|
254
273
|
readonly measures: M;
|
|
255
274
|
readonly dimensions: Dm;
|
|
256
275
|
};
|
|
@@ -272,7 +291,7 @@ type CheckDimensions<Dm, T extends string> = {
|
|
|
272
291
|
time: T;
|
|
273
292
|
} : unknown;
|
|
274
293
|
};
|
|
275
|
-
export type DatasetDefinition<K extends string, M extends Record<string, AnyMeasure>, Dm extends Record<string, AnyDimension>, T extends string> = {
|
|
294
|
+
export type DatasetDefinition<K extends string, M extends Record<string, AnyMeasure>, Dm extends Record<string, AnyDimension>, T extends string, P extends PeriodInput> = {
|
|
276
295
|
key: K;
|
|
277
296
|
label: string;
|
|
278
297
|
/** Tells a reader (and a model) what one row of the dataset is. */
|
|
@@ -282,8 +301,12 @@ export type DatasetDefinition<K extends string, M extends Record<string, AnyMeas
|
|
|
282
301
|
};
|
|
283
302
|
/** Which source (database) the dataset runs on; the default when there is one source. */
|
|
284
303
|
source?: string | undefined;
|
|
285
|
-
/**
|
|
286
|
-
|
|
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;
|
|
287
310
|
/** Always applied, for example excluding test rows. */
|
|
288
311
|
scope?: Expr<'boolean'> | undefined;
|
|
289
312
|
time: Record<T, {
|
|
@@ -292,6 +315,11 @@ export type DatasetDefinition<K extends string, M extends Record<string, AnyMeas
|
|
|
292
315
|
}>;
|
|
293
316
|
/** The time field periods apply to when a query names none; the first one by default. */
|
|
294
317
|
defaultTime?: NoInfer<T> | undefined;
|
|
318
|
+
/**
|
|
319
|
+
* The period of a query that names none; the last 30 days by default. A dataset of current
|
|
320
|
+
* state (units, organizations) sets `{ all: true }`.
|
|
321
|
+
*/
|
|
322
|
+
defaultPeriod?: P | undefined;
|
|
295
323
|
measures: M & CheckMeasures<M>;
|
|
296
324
|
dimensions: Dm & CheckDimensions<Dm, NoInfer<T>>;
|
|
297
325
|
};
|
|
@@ -299,5 +327,5 @@ export type DatasetDefinition<K extends string, M extends Record<string, AnyMeas
|
|
|
299
327
|
* Declares a dataset: one fact table with its tenant column, time fields, measures and
|
|
300
328
|
* dimensions. Keys stay literal types, so queries and result rows are typed from it.
|
|
301
329
|
*/
|
|
302
|
-
export declare function defineDataset<const K extends string, const M extends Record<string, AnyMeasure>, const Dm extends Record<string, AnyDimension>, const T extends string>(definition: DatasetDefinition<K, M, Dm, T>): Dataset<K, M, Dm, T>;
|
|
330
|
+
export declare function defineDataset<const K extends string, const M extends Record<string, AnyMeasure>, const Dm extends Record<string, AnyDimension>, const T extends string, const P extends PeriodInput = typeof DEFAULT_PERIOD>(definition: DatasetDefinition<K, M, Dm, T, P>): Dataset<K, M, Dm, T, P>;
|
|
303
331
|
export {};
|
package/dist/guidance.d.ts
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
* How a model should use an analytics query tool, as markdown bullets for its instructions.
|
|
3
3
|
* It does not name the tool, so add a line that does.
|
|
4
4
|
*/
|
|
5
|
-
export declare const analyticsToolGuidance = "- Answer every \"how many\", \"how much\", \"per week\" or \"split by\" question with the analytics tool. Never count by hand from lists or pages.\n- Pick the dataset whose description matches the question, the fewest measures that answer it and at most two groupings.\n- Say which period and filters the numbers cover, using the dates in the result (\"1-30 September\").\n- Compare with the previous period (compareToPrevious) when the change is the point, such as \"is it going up\".\n- When the result has notes (overlapping rows, such as a record counted once per tag), say so in a few words.\n- Ask one short question only when the period is genuinely ambiguous (\"this season\") and someone can answer; otherwise take the natural reading and state it.";
|
|
5
|
+
export declare const analyticsToolGuidance = "- Answer every \"how many\", \"how much\", \"per week\" or \"split by\" question with the analytics tool. Never count by hand from lists or pages.\n- Pick the dataset whose description matches the question, the fewest measures that answer it and at most two groupings.\n- Say which period and filters the numbers cover, using the dates in the result (\"1-30 September\", or \"all time\").\n- Compare with the previous period (compareToPrevious) when the change is the point, such as \"is it going up\".\n- When the result has notes (overlapping rows, such as a record counted once per tag), say so in a few words.\n- Ask one short question only when the period is genuinely ambiguous (\"this season\") and someone can answer; otherwise take the natural reading and state it.";
|