measurr 0.3.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 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 query.
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
- `authorize` is the one access rule. Everything that offers datasets goes through it:
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
- - `analytics.querySchema({ ctx })` resolves to the input schema for those datasets, for a tool
125
- built per caller. It rejects with `AnalyticsError('forbidden')` when nothing is allowed.
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 are built once per set of datasets, so
128
- `querySchema({ ctx })` returns the same object as `querySchema({ datasets })` for the same
129
- allowed set: cache a tool built from it by its dataset keys.
130
- - `analytics.query` checks `authorize` again on every call, so a schema offered too widely
131
- still cannot leak a dataset.
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
@@ -196,13 +224,33 @@ checking the session) before building `ctx`; never read it from the query input.
196
224
  lookups resolve labels across tenants, so `"Complaint"` matches every tenant's tag of that
197
225
  name.
198
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
+
199
247
  ## The model
200
248
 
201
249
  | Concept | What it is |
202
250
  | --- | --- |
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). |
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`. |
206
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). |
207
255
  | Result | `{ period, rows, totals, previous?, notes, truncated }`; `period` is `{ from, to, timezone }` or `{ all: true, timezone }`. |
208
256
 
@@ -344,12 +392,22 @@ It checks that every measure runs and returns a number or null, every grouping r
344
392
  groups add up to the total (except many-to-many), `isEmpty` plus `isNotEmpty` equals the total,
345
393
  open dimensions filter by label to their group's count, closed dimensions filter by value, an
346
394
  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.
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
+ ```
348
405
 
349
406
  ## Extending
350
407
 
351
408
  - **A measure or dimension** is one entry in a dataset. Dimensions are groupable and
352
- filterable unless `groupOnly` or `filterOnly` says otherwise. The contract kit covers it
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
353
411
  without a new test.
354
412
  - **A dataset** is one `defineDataset` call added to `createAnalytics({ datasets })`.
355
413
  - **A computed value** is written with the expression builders (`caseWhen`, `exists`,
@@ -362,15 +420,16 @@ far-from-UTC timezone (default `Pacific/Auckland`) agree with single-day periods
362
420
 
363
421
  ## Stable API
364
422
 
365
- Kept stable for publishing: `createAnalytics` and its options, `allTenants`, `analytics.query`,
366
- `explain`, `querySchema`, `resultSchema`, `listDatasets`; `AnalyticsResultSchema`,
367
- `analyticsToolGuidance`, `AnalyticsQueryEvent`; `defineDataset` (with `defaultPeriod`),
368
- `measure.*`, `dimension.*`, `table` / `Table`, the expression builders; the `Dialect`,
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`,
369
428
  `SqlRenderer` and `AnalyticsSource` contracts; `postgresDialect`, `checkDatasetContract` /
370
429
  `assertDatasetContract`; and the types `AnalyticsQuery`, `AnalyticsResult`, `ResultPeriod`,
371
- `PeriodInput`, `Tenant`, `TenantId`, `Ratio`, `AnalyticsError` codes and their details
372
- (`isAnalyticsError`). `ExprNode` and `SelectStatement` are
373
- exported for dialect authors and may grow new node kinds.
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.
374
433
 
375
434
  ## Install
376
435
 
@@ -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,6 +53,10 @@ 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. */
@@ -69,6 +73,14 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
69
73
  * `listDatasets(ctx)` and `querySchema({ ctx })` offer, and is checked again on every query.
70
74
  */
71
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;
72
84
  /** IANA timezone periods and time buckets resolve in. UTC when omitted. */
73
85
  timezone?: ((ctx: Ctx) => string | null | undefined | Promise<string | null | undefined>) | undefined;
74
86
  /** The clock periods and `minutesAgo` resolve against. */
@@ -123,15 +135,15 @@ export type Analytics<DS extends readonly AnyDataset[], Ctx> = {
123
135
  key: K;
124
136
  }>>>;
125
137
  /**
126
- * The input schema for the datasets `authorize` allows `ctx`, for a tool built per caller.
127
- * 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.
128
140
  */
129
141
  querySchema(options: {
130
142
  ctx: Ctx;
131
143
  }): Promise<z.ZodType<DatasetQuery<DS[number]>>>;
132
144
  /** The result schema for one dataset. */
133
145
  resultSchema(dataset: DS[number]['key']): z.ZodType<QueryResult<Row, Row>>;
134
- /** The datasets `authorize` allows `ctx`, with their measures and dimensions. */
146
+ /** The datasets `ctx` may query, with the measures and dimensions it may use. */
135
147
  listDatasets(ctx: Ctx): Promise<DatasetCatalogEntry<DS[number]['key']>[]>;
136
148
  };
137
149
  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
- readonly tenantColumn: ExprNode;
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
- /** The column every query is filtered by; the value comes from `tenant(ctx)`. */
289
- tenantColumn: Expr<SqlType>;
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, {