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/src/dataset.ts
CHANGED
|
@@ -9,15 +9,27 @@ import {
|
|
|
9
9
|
directChildren,
|
|
10
10
|
rootOf
|
|
11
11
|
} from './expr.js'
|
|
12
|
+
import { DEFAULT_PERIOD, type PeriodInput } from './period.js'
|
|
12
13
|
|
|
13
14
|
declare const ratioBrand: unique symbol
|
|
14
15
|
|
|
15
16
|
/** A share or ratio between 0 and 1. Branded so nobody renders 0.42 as "0.42%". */
|
|
16
17
|
export type Ratio = number & { readonly [ratioBrand]: 'zeroToOne' }
|
|
17
18
|
|
|
18
|
-
type Described = {
|
|
19
|
+
type Described<Access extends string = string> = {
|
|
19
20
|
label: string
|
|
20
21
|
description?: string | undefined
|
|
22
|
+
/**
|
|
23
|
+
* Restricts the field to callers `authorizeField` allows for this access key (a string the
|
|
24
|
+
* app defines, such as `'staff'`). Unmarked fields are open to everyone who may query the
|
|
25
|
+
* dataset.
|
|
26
|
+
*/
|
|
27
|
+
access?: Access | undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Keeps a field's access key literal, so `authorizeField` is typed with the keys in use. */
|
|
31
|
+
export type Marked<Access extends string> = {
|
|
32
|
+
readonly access: Access | undefined
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -36,6 +48,7 @@ export type AggregateMeasure<
|
|
|
36
48
|
readonly result: R
|
|
37
49
|
readonly label: string
|
|
38
50
|
readonly description: string | undefined
|
|
51
|
+
readonly access: string | undefined
|
|
39
52
|
readonly node: Extract<ExprNode, { kind: 'aggregate' }>
|
|
40
53
|
}
|
|
41
54
|
|
|
@@ -44,6 +57,7 @@ export type ShareMeasure = {
|
|
|
44
57
|
readonly result: 'ratio'
|
|
45
58
|
readonly label: string
|
|
46
59
|
readonly description: string | undefined
|
|
60
|
+
readonly access: string | undefined
|
|
47
61
|
readonly numerator: ExprNode
|
|
48
62
|
readonly denominator: ExprNode | null
|
|
49
63
|
}
|
|
@@ -56,6 +70,8 @@ export type RatioMeasure<
|
|
|
56
70
|
readonly result: 'ratio'
|
|
57
71
|
readonly label: string
|
|
58
72
|
readonly description: string | undefined
|
|
73
|
+
/** A ratio also needs the access of the measures it divides. */
|
|
74
|
+
readonly access: string | undefined
|
|
59
75
|
readonly numerator: A
|
|
60
76
|
readonly denominator: B
|
|
61
77
|
}
|
|
@@ -69,19 +85,22 @@ export type MeasureValue<M> = M extends { result: 'count' }
|
|
|
69
85
|
? number | null
|
|
70
86
|
: Ratio | null
|
|
71
87
|
|
|
72
|
-
type Filtered = Described & {
|
|
88
|
+
type Filtered<Access extends string> = Described<Access> & {
|
|
89
|
+
where?: Expr<'boolean'> | undefined
|
|
90
|
+
}
|
|
73
91
|
|
|
74
|
-
function aggregate<R extends 'count' | 'number'>(
|
|
92
|
+
function aggregate<R extends 'count' | 'number', Access extends string>(
|
|
75
93
|
result: R,
|
|
76
94
|
fn: Extract<ExprNode, { kind: 'aggregate' }>['fn'],
|
|
77
95
|
arg: Expr<SqlType> | null,
|
|
78
|
-
options: Filtered
|
|
79
|
-
): AggregateMeasure<R> {
|
|
96
|
+
options: Filtered<Access>
|
|
97
|
+
): AggregateMeasure<R> & Marked<NoInfer<Access>> {
|
|
80
98
|
return {
|
|
81
99
|
kind: 'aggregate',
|
|
82
100
|
result,
|
|
83
101
|
label: options.label,
|
|
84
102
|
description: options.description,
|
|
103
|
+
access: options.access,
|
|
85
104
|
node: {
|
|
86
105
|
kind: 'aggregate',
|
|
87
106
|
fn,
|
|
@@ -93,35 +112,46 @@ function aggregate<R extends 'count' | 'number'>(
|
|
|
93
112
|
|
|
94
113
|
export const measure = {
|
|
95
114
|
/** Number of rows, optionally only those matching `where`. */
|
|
96
|
-
count
|
|
115
|
+
count<Access extends string = never>(
|
|
116
|
+
options: Filtered<Access>
|
|
117
|
+
): AggregateMeasure<'count'> & Marked<NoInfer<Access>> {
|
|
97
118
|
return aggregate('count', 'count', null, options)
|
|
98
119
|
},
|
|
99
120
|
|
|
100
121
|
/** Number of rows matching `condition`. */
|
|
101
|
-
countWhere(
|
|
122
|
+
countWhere<Access extends string = never>(
|
|
102
123
|
condition: Expr<'boolean'>,
|
|
103
|
-
options: Described
|
|
104
|
-
): AggregateMeasure<'count'> {
|
|
124
|
+
options: Described<Access>
|
|
125
|
+
): AggregateMeasure<'count'> & Marked<NoInfer<Access>> {
|
|
105
126
|
return aggregate('count', 'count', null, { ...options, where: condition })
|
|
106
127
|
},
|
|
107
128
|
|
|
108
129
|
/** Number of distinct non-null values of `expr`. */
|
|
109
|
-
countDistinct(
|
|
130
|
+
countDistinct<Access extends string = never>(
|
|
110
131
|
expr: Expr<SqlType>,
|
|
111
|
-
options: Filtered
|
|
112
|
-
): AggregateMeasure<'count'> {
|
|
132
|
+
options: Filtered<Access>
|
|
133
|
+
): AggregateMeasure<'count'> & Marked<NoInfer<Access>> {
|
|
113
134
|
return aggregate('count', 'countDistinct', expr, options)
|
|
114
135
|
},
|
|
115
136
|
|
|
116
|
-
sum
|
|
137
|
+
sum<Access extends string = never>(
|
|
138
|
+
expr: Expr<'number'>,
|
|
139
|
+
options: Filtered<Access>
|
|
140
|
+
): AggregateMeasure<'number'> & Marked<NoInfer<Access>> {
|
|
117
141
|
return aggregate('number', 'sum', expr, options)
|
|
118
142
|
},
|
|
119
143
|
|
|
120
|
-
avg
|
|
144
|
+
avg<Access extends string = never>(
|
|
145
|
+
expr: Expr<'number'>,
|
|
146
|
+
options: Filtered<Access>
|
|
147
|
+
): AggregateMeasure<'number'> & Marked<NoInfer<Access>> {
|
|
121
148
|
return aggregate('number', 'avg', expr, options)
|
|
122
149
|
},
|
|
123
150
|
|
|
124
|
-
median
|
|
151
|
+
median<Access extends string = never>(
|
|
152
|
+
expr: Expr<'number'>,
|
|
153
|
+
options: Filtered<Access>
|
|
154
|
+
): AggregateMeasure<'number'> & Marked<NoInfer<Access>> {
|
|
125
155
|
return aggregate('number', 'median', expr, options)
|
|
126
156
|
},
|
|
127
157
|
|
|
@@ -129,17 +159,18 @@ export const measure = {
|
|
|
129
159
|
* The share of rows matching `numerator` among rows matching `denominator` (all rows when
|
|
130
160
|
* omitted). A `Ratio` from 0 to 1.
|
|
131
161
|
*/
|
|
132
|
-
share(
|
|
133
|
-
options: Described & {
|
|
162
|
+
share<Access extends string = never>(
|
|
163
|
+
options: Described<Access> & {
|
|
134
164
|
numerator: Expr<'boolean'>
|
|
135
165
|
denominator?: Expr<'boolean'> | undefined
|
|
136
166
|
}
|
|
137
|
-
): ShareMeasure {
|
|
167
|
+
): ShareMeasure & Marked<NoInfer<Access>> {
|
|
138
168
|
return {
|
|
139
169
|
kind: 'share',
|
|
140
170
|
result: 'ratio',
|
|
141
171
|
label: options.label,
|
|
142
172
|
description: options.description,
|
|
173
|
+
access: options.access,
|
|
143
174
|
numerator: options.numerator.node,
|
|
144
175
|
denominator: options.denominator?.node ?? null
|
|
145
176
|
}
|
|
@@ -149,16 +180,21 @@ export const measure = {
|
|
|
149
180
|
* One measure of this dataset over another, computed after aggregation. Both keys must be
|
|
150
181
|
* measures of the same dataset; `defineDataset` checks them.
|
|
151
182
|
*/
|
|
152
|
-
ratio<
|
|
183
|
+
ratio<
|
|
184
|
+
const A extends string,
|
|
185
|
+
const B extends string,
|
|
186
|
+
Access extends string = never
|
|
187
|
+
>(
|
|
153
188
|
numerator: A,
|
|
154
189
|
denominator: B,
|
|
155
|
-
options: Described
|
|
156
|
-
): RatioMeasure<A, B> {
|
|
190
|
+
options: Described<Access>
|
|
191
|
+
): RatioMeasure<A, B> & Marked<NoInfer<Access>> {
|
|
157
192
|
return {
|
|
158
193
|
kind: 'ratio',
|
|
159
194
|
result: 'ratio',
|
|
160
195
|
label: options.label,
|
|
161
196
|
description: options.description,
|
|
197
|
+
access: options.access,
|
|
162
198
|
numerator,
|
|
163
199
|
denominator
|
|
164
200
|
}
|
|
@@ -180,8 +216,9 @@ export type DimensionCategory = 'closed' | 'open' | 'numeric' | 'time'
|
|
|
180
216
|
|
|
181
217
|
type DimensionOptions<
|
|
182
218
|
G extends boolean = boolean,
|
|
183
|
-
F extends boolean = boolean
|
|
184
|
-
|
|
219
|
+
F extends boolean = boolean,
|
|
220
|
+
Access extends string = string
|
|
221
|
+
> = Described<Access> & {
|
|
185
222
|
/** Only offered for grouping. */
|
|
186
223
|
groupOnly?: G | undefined
|
|
187
224
|
/** Only offered for filtering. */
|
|
@@ -189,19 +226,25 @@ type DimensionOptions<
|
|
|
189
226
|
}
|
|
190
227
|
|
|
191
228
|
/** Time dimensions name themselves after their unit unless given a label. */
|
|
192
|
-
type TimeDimensionOptions<
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
229
|
+
type TimeDimensionOptions<
|
|
230
|
+
G extends boolean,
|
|
231
|
+
F extends boolean,
|
|
232
|
+
Key,
|
|
233
|
+
Access extends string
|
|
234
|
+
> = Omit<DimensionOptions<G, F, Access>, 'label'> & {
|
|
196
235
|
label?: string | undefined
|
|
197
236
|
labelFor?: ((key: Key) => string) | undefined
|
|
198
237
|
}
|
|
199
238
|
|
|
200
239
|
/**
|
|
201
240
|
* `groupOnly: true` makes `filterable` the literal `false` (and `filterOnly`, `groupable`), so
|
|
202
|
-
* queries cannot filter or group by what the dimension does not offer.
|
|
241
|
+
* queries cannot filter or group by what the dimension does not offer. `access` stays literal.
|
|
203
242
|
*/
|
|
204
|
-
export type DimensionAccess<
|
|
243
|
+
export type DimensionAccess<
|
|
244
|
+
G extends boolean,
|
|
245
|
+
F extends boolean,
|
|
246
|
+
Access extends string = never
|
|
247
|
+
> = Marked<Access> & {
|
|
205
248
|
readonly groupable: [F] extends [true] ? false : boolean
|
|
206
249
|
readonly filterable: [G] extends [true] ? false : boolean
|
|
207
250
|
}
|
|
@@ -210,6 +253,7 @@ type DimensionBase<C extends DimensionCategory, Key> = {
|
|
|
210
253
|
readonly category: C
|
|
211
254
|
readonly label: string
|
|
212
255
|
readonly description: string | undefined
|
|
256
|
+
readonly access: string | undefined
|
|
213
257
|
readonly groupable: boolean
|
|
214
258
|
readonly filterable: boolean
|
|
215
259
|
/** Phantom: the key type in result rows. */
|
|
@@ -316,23 +360,32 @@ function base(options: DimensionOptions) {
|
|
|
316
360
|
return {
|
|
317
361
|
label: options.label,
|
|
318
362
|
description: options.description,
|
|
363
|
+
access: options.access,
|
|
319
364
|
groupable: options.filterOnly !== true,
|
|
320
365
|
filterable: options.groupOnly !== true
|
|
321
366
|
}
|
|
322
367
|
}
|
|
323
368
|
|
|
324
|
-
/**
|
|
369
|
+
/**
|
|
370
|
+
* Narrows `groupable`, `filterable` and `access` (which `base` copied) to what the options
|
|
371
|
+
* say.
|
|
372
|
+
*/
|
|
325
373
|
function withAccess<
|
|
326
374
|
D extends AnyDimension,
|
|
327
375
|
G extends boolean,
|
|
328
|
-
F extends boolean
|
|
376
|
+
F extends boolean,
|
|
377
|
+
Access extends string
|
|
329
378
|
>(
|
|
330
379
|
_options:
|
|
331
|
-
| {
|
|
380
|
+
| {
|
|
381
|
+
groupOnly?: G | undefined
|
|
382
|
+
filterOnly?: F | undefined
|
|
383
|
+
access?: Access | undefined
|
|
384
|
+
}
|
|
332
385
|
| undefined,
|
|
333
386
|
dimension: D
|
|
334
|
-
): D & DimensionAccess<G, F
|
|
335
|
-
return dimension as D & DimensionAccess<G, F>
|
|
387
|
+
): D & DimensionAccess<G, F, NoInfer<Access>> {
|
|
388
|
+
return dimension as D & DimensionAccess<G, F, Access>
|
|
336
389
|
}
|
|
337
390
|
|
|
338
391
|
export const dimension = {
|
|
@@ -345,13 +398,15 @@ export const dimension = {
|
|
|
345
398
|
const E extends Record<string, string>,
|
|
346
399
|
V extends string | null,
|
|
347
400
|
G extends boolean = boolean,
|
|
348
|
-
F extends boolean = boolean
|
|
401
|
+
F extends boolean = boolean,
|
|
402
|
+
Access extends string = never
|
|
349
403
|
>(
|
|
350
404
|
expr: Expr<'string', V> & CoversValues<V, E[keyof E]>,
|
|
351
405
|
values: E,
|
|
352
|
-
options: DimensionOptions<G, F> &
|
|
406
|
+
options: DimensionOptions<G, F, Access> &
|
|
353
407
|
EmptyOption & { labels: Record<E[keyof E], string> }
|
|
354
|
-
): EnumDimension<E[keyof E], EmptyOf<V>> &
|
|
408
|
+
): EnumDimension<E[keyof E], EmptyOf<V>> &
|
|
409
|
+
DimensionAccess<G, F, NoInfer<Access>> {
|
|
355
410
|
for (const value of Object.values(values)) {
|
|
356
411
|
if (!(value in options.labels)) {
|
|
357
412
|
throw new Error(`${options.label}: the value ${value} has no label`)
|
|
@@ -378,14 +433,15 @@ export const dimension = {
|
|
|
378
433
|
V extends string | null,
|
|
379
434
|
const K extends string,
|
|
380
435
|
G extends boolean = boolean,
|
|
381
|
-
F extends boolean = boolean
|
|
436
|
+
F extends boolean = boolean,
|
|
437
|
+
Access extends string = never
|
|
382
438
|
>(
|
|
383
|
-
options: DimensionOptions<G, F> &
|
|
439
|
+
options: DimensionOptions<G, F, Access> &
|
|
384
440
|
EmptyOption & {
|
|
385
441
|
expr: Expr<'string', V> & CoversValues<V, NoInfer<K>>
|
|
386
442
|
values: Record<K, string>
|
|
387
443
|
}
|
|
388
|
-
): EnumDimension<K, EmptyOf<V>> & DimensionAccess<G, F
|
|
444
|
+
): EnumDimension<K, EmptyOf<V>> & DimensionAccess<G, F, NoInfer<Access>> {
|
|
389
445
|
return withAccess(options, {
|
|
390
446
|
...base(options),
|
|
391
447
|
category: 'closed',
|
|
@@ -404,14 +460,15 @@ export const dimension = {
|
|
|
404
460
|
relation<
|
|
405
461
|
Key extends string | number,
|
|
406
462
|
G extends boolean = boolean,
|
|
407
|
-
F extends boolean = boolean
|
|
463
|
+
F extends boolean = boolean,
|
|
464
|
+
Access extends string = never
|
|
408
465
|
>(
|
|
409
|
-
options: DimensionOptions<G, F> & {
|
|
466
|
+
options: DimensionOptions<G, F, Access> & {
|
|
410
467
|
key: Expr<Key extends number ? 'number' : 'string', Key | null>
|
|
411
468
|
name: Expr<'string'>
|
|
412
469
|
empty?: string | undefined
|
|
413
470
|
}
|
|
414
|
-
): RelationDimension<Key> & DimensionAccess<G, F
|
|
471
|
+
): RelationDimension<Key> & DimensionAccess<G, F, NoInfer<Access>> {
|
|
415
472
|
return withAccess(options, {
|
|
416
473
|
...base(options),
|
|
417
474
|
category: 'open',
|
|
@@ -433,9 +490,10 @@ export const dimension = {
|
|
|
433
490
|
manyToMany<
|
|
434
491
|
Row = unknown,
|
|
435
492
|
G extends boolean = boolean,
|
|
436
|
-
F extends boolean = boolean
|
|
493
|
+
F extends boolean = boolean,
|
|
494
|
+
Access extends string = never
|
|
437
495
|
>(
|
|
438
|
-
options: DimensionOptions<G, F> & {
|
|
496
|
+
options: DimensionOptions<G, F, Access> & {
|
|
439
497
|
through: {
|
|
440
498
|
table: Table<Row>
|
|
441
499
|
on: Expr<'boolean'>
|
|
@@ -446,7 +504,7 @@ export const dimension = {
|
|
|
446
504
|
note?: string | undefined
|
|
447
505
|
empty?: string | undefined
|
|
448
506
|
}
|
|
449
|
-
): ManyToManyDimension & DimensionAccess<G, F
|
|
507
|
+
): ManyToManyDimension & DimensionAccess<G, F, NoInfer<Access>> {
|
|
450
508
|
return withAccess(options, {
|
|
451
509
|
...base(options),
|
|
452
510
|
category: 'open',
|
|
@@ -461,12 +519,16 @@ export const dimension = {
|
|
|
461
519
|
},
|
|
462
520
|
|
|
463
521
|
/** A numeric column, grouped by its exact values and filtered with `between`. */
|
|
464
|
-
number<
|
|
522
|
+
number<
|
|
523
|
+
G extends boolean = boolean,
|
|
524
|
+
F extends boolean = boolean,
|
|
525
|
+
Access extends string = never
|
|
526
|
+
>(
|
|
465
527
|
expr: Expr<'number'>,
|
|
466
|
-
options: DimensionOptions<G, F> & {
|
|
528
|
+
options: DimensionOptions<G, F, Access> & {
|
|
467
529
|
labelFor?: ((key: number) => string) | undefined
|
|
468
530
|
}
|
|
469
|
-
): NumberDimension & DimensionAccess<G, F
|
|
531
|
+
): NumberDimension & DimensionAccess<G, F, NoInfer<Access>> {
|
|
470
532
|
return withAccess(options, {
|
|
471
533
|
...base(options),
|
|
472
534
|
category: 'numeric',
|
|
@@ -480,12 +542,13 @@ export const dimension = {
|
|
|
480
542
|
timeBucket<
|
|
481
543
|
const T extends string,
|
|
482
544
|
G extends boolean = boolean,
|
|
483
|
-
F extends boolean = boolean
|
|
545
|
+
F extends boolean = boolean,
|
|
546
|
+
Access extends string = never
|
|
484
547
|
>(
|
|
485
548
|
time: T,
|
|
486
549
|
unit: TimeBucketUnit,
|
|
487
|
-
options?: TimeDimensionOptions<G, F, string>
|
|
488
|
-
): TimeBucketDimension<T> & DimensionAccess<G, F
|
|
550
|
+
options?: TimeDimensionOptions<G, F, string, Access>
|
|
551
|
+
): TimeBucketDimension<T> & DimensionAccess<G, F, NoInfer<Access>> {
|
|
489
552
|
return withAccess(options, {
|
|
490
553
|
...base({
|
|
491
554
|
...options,
|
|
@@ -503,12 +566,13 @@ export const dimension = {
|
|
|
503
566
|
timePart<
|
|
504
567
|
const T extends string,
|
|
505
568
|
G extends boolean = boolean,
|
|
506
|
-
F extends boolean = boolean
|
|
569
|
+
F extends boolean = boolean,
|
|
570
|
+
Access extends string = never
|
|
507
571
|
>(
|
|
508
572
|
time: T,
|
|
509
573
|
part: TimePartName,
|
|
510
|
-
options?: TimeDimensionOptions<G, F, number>
|
|
511
|
-
): TimePartDimension<T> & DimensionAccess<G, F
|
|
574
|
+
options?: TimeDimensionOptions<G, F, number, Access>
|
|
575
|
+
): TimePartDimension<T> & DimensionAccess<G, F, NoInfer<Access>> {
|
|
512
576
|
return withAccess(options, {
|
|
513
577
|
...base({
|
|
514
578
|
...options,
|
|
@@ -532,17 +596,21 @@ export type Dataset<
|
|
|
532
596
|
K extends string = string,
|
|
533
597
|
M extends Record<string, AnyMeasure> = Record<string, AnyMeasure>,
|
|
534
598
|
Dm extends Record<string, AnyDimension> = Record<string, AnyDimension>,
|
|
535
|
-
T extends string = string
|
|
599
|
+
T extends string = string,
|
|
600
|
+
P extends PeriodInput = PeriodInput
|
|
536
601
|
> = {
|
|
537
602
|
readonly key: K
|
|
538
603
|
readonly label: string
|
|
539
604
|
readonly description: string
|
|
540
605
|
readonly from: TableRef
|
|
541
606
|
readonly source: string | undefined
|
|
542
|
-
|
|
607
|
+
/** Null for a dataset without tenants, which only `allTenants` may query. */
|
|
608
|
+
readonly tenantColumn: ExprNode | null
|
|
543
609
|
readonly scope: ExprNode | null
|
|
544
610
|
readonly time: Readonly<Record<T, TimeField>>
|
|
545
611
|
readonly defaultTime: T
|
|
612
|
+
/** Kept as a type, so results know whether a query without a period covers all time. */
|
|
613
|
+
readonly defaultPeriod: P
|
|
546
614
|
readonly measures: M
|
|
547
615
|
readonly dimensions: Dm
|
|
548
616
|
}
|
|
@@ -571,7 +639,8 @@ export type DatasetDefinition<
|
|
|
571
639
|
K extends string,
|
|
572
640
|
M extends Record<string, AnyMeasure>,
|
|
573
641
|
Dm extends Record<string, AnyDimension>,
|
|
574
|
-
T extends string
|
|
642
|
+
T extends string,
|
|
643
|
+
P extends PeriodInput
|
|
575
644
|
> = {
|
|
576
645
|
key: K
|
|
577
646
|
label: string
|
|
@@ -580,13 +649,22 @@ export type DatasetDefinition<
|
|
|
580
649
|
from: { readonly ref: TableRef }
|
|
581
650
|
/** Which source (database) the dataset runs on; the default when there is one source. */
|
|
582
651
|
source?: string | undefined
|
|
583
|
-
/**
|
|
584
|
-
|
|
652
|
+
/**
|
|
653
|
+
* The column every query is filtered by; the value comes from `tenant(ctx)`. `null` for a
|
|
654
|
+
* dataset whose rows belong to no tenant (staff work, platform-wide records): only a caller
|
|
655
|
+
* whose `tenant(ctx)` is `allTenants` may query it, and everyone else is refused.
|
|
656
|
+
*/
|
|
657
|
+
tenantColumn: Expr<SqlType> | null
|
|
585
658
|
/** Always applied, for example excluding test rows. */
|
|
586
659
|
scope?: Expr<'boolean'> | undefined
|
|
587
660
|
time: Record<T, { column: Expr<'timestamp'>; label: string }>
|
|
588
661
|
/** The time field periods apply to when a query names none; the first one by default. */
|
|
589
662
|
defaultTime?: NoInfer<T> | undefined
|
|
663
|
+
/**
|
|
664
|
+
* The period of a query that names none; the last 30 days by default. A dataset of current
|
|
665
|
+
* state (units, organizations) sets `{ all: true }`.
|
|
666
|
+
*/
|
|
667
|
+
defaultPeriod?: P | undefined
|
|
590
668
|
measures: M & CheckMeasures<M>
|
|
591
669
|
dimensions: Dm & CheckDimensions<Dm, NoInfer<T>>
|
|
592
670
|
}
|
|
@@ -599,8 +677,9 @@ export function defineDataset<
|
|
|
599
677
|
const K extends string,
|
|
600
678
|
const M extends Record<string, AnyMeasure>,
|
|
601
679
|
const Dm extends Record<string, AnyDimension>,
|
|
602
|
-
const T extends string
|
|
603
|
-
|
|
680
|
+
const T extends string,
|
|
681
|
+
const P extends PeriodInput = typeof DEFAULT_PERIOD
|
|
682
|
+
>(definition: DatasetDefinition<K, M, Dm, T, P>): Dataset<K, M, Dm, T, P> {
|
|
604
683
|
const timeEntries = Object.entries<{
|
|
605
684
|
column: Expr<'timestamp'>
|
|
606
685
|
label: string
|
|
@@ -612,6 +691,13 @@ export function defineDataset<
|
|
|
612
691
|
)
|
|
613
692
|
}
|
|
614
693
|
|
|
694
|
+
// A JavaScript caller that leaves it out must not get a dataset without tenants.
|
|
695
|
+
if (definition.tenantColumn === undefined) {
|
|
696
|
+
throw new Error(
|
|
697
|
+
`Dataset ${definition.key}: declare tenantColumn, or null for a dataset without tenants`
|
|
698
|
+
)
|
|
699
|
+
}
|
|
700
|
+
|
|
615
701
|
const time = Object.fromEntries(
|
|
616
702
|
timeEntries.map(([key, field]) => [
|
|
617
703
|
key,
|
|
@@ -619,16 +705,18 @@ export function defineDataset<
|
|
|
619
705
|
])
|
|
620
706
|
) as Record<T, TimeField>
|
|
621
707
|
|
|
622
|
-
const dataset: Dataset<K, M, Dm, T> = {
|
|
708
|
+
const dataset: Dataset<K, M, Dm, T, P> = {
|
|
623
709
|
key: definition.key,
|
|
624
710
|
label: definition.label,
|
|
625
711
|
description: definition.description,
|
|
626
712
|
from: definition.from.ref,
|
|
627
713
|
source: definition.source,
|
|
628
|
-
tenantColumn: definition.tenantColumn
|
|
714
|
+
tenantColumn: definition.tenantColumn?.node ?? null,
|
|
629
715
|
scope: definition.scope?.node ?? null,
|
|
630
716
|
time,
|
|
631
717
|
defaultTime: definition.defaultTime ?? (firstTime[0] as T),
|
|
718
|
+
// P is DEFAULT_PERIOD's type when the definition names no period.
|
|
719
|
+
defaultPeriod: definition.defaultPeriod ?? (DEFAULT_PERIOD as P),
|
|
632
720
|
measures: definition.measures,
|
|
633
721
|
dimensions: definition.dimensions
|
|
634
722
|
}
|
|
@@ -662,7 +750,7 @@ function validateDataset<DS extends AnyDataset>(dataset: DS): DS {
|
|
|
662
750
|
})
|
|
663
751
|
}
|
|
664
752
|
|
|
665
|
-
check(dataset.tenantColumn, 'tenantColumn')
|
|
753
|
+
if (dataset.tenantColumn) check(dataset.tenantColumn, 'tenantColumn')
|
|
666
754
|
if (dataset.scope) check(dataset.scope, 'scope')
|
|
667
755
|
for (const [key, field] of Object.entries<TimeField>(dataset.time)) {
|
|
668
756
|
check(field.column, `time field ${key}`)
|
package/src/guidance.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export 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.
|
|
6
6
|
- Pick the dataset whose description matches the question, the fewest measures that answer it and at most two groupings.
|
|
7
|
-
- Say which period and filters the numbers cover, using the dates in the result ("1-30 September").
|
|
7
|
+
- Say which period and filters the numbers cover, using the dates in the result ("1-30 September", or "all time").
|
|
8
8
|
- Compare with the previous period (compareToPrevious) when the change is the point, such as "is it going up".
|
|
9
9
|
- When the result has notes (overlapping rows, such as a record counted once per tag), say so in a few words.
|
|
10
10
|
- 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.`
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export {
|
|
2
|
+
type AccessKey,
|
|
2
3
|
type Analytics,
|
|
3
4
|
AnalyticsError,
|
|
4
5
|
type AnalyticsErrorCode,
|
|
@@ -11,6 +12,7 @@ export {
|
|
|
11
12
|
type AnalyticsSource,
|
|
12
13
|
createAnalytics,
|
|
13
14
|
type DatasetCatalogEntry,
|
|
15
|
+
type FieldKey,
|
|
14
16
|
isAnalyticsError
|
|
15
17
|
} from './analytics.js'
|
|
16
18
|
export {
|
|
@@ -65,11 +67,12 @@ export {
|
|
|
65
67
|
} from './expr.js'
|
|
66
68
|
export { analyticsToolGuidance } from './guidance.js'
|
|
67
69
|
export type { PeriodInput, PeriodPreset } from './period.js'
|
|
68
|
-
export { allTenants, type Tenant } from './plan.js'
|
|
70
|
+
export { allTenants, type Tenant, type TenantId } from './plan.js'
|
|
69
71
|
export {
|
|
70
72
|
AnalyticsResultSchema,
|
|
71
73
|
type DatasetFilter,
|
|
72
74
|
type DatasetQuery,
|
|
73
75
|
type QueryResult,
|
|
74
|
-
type ResultOf
|
|
76
|
+
type ResultOf,
|
|
77
|
+
type ResultPeriod
|
|
75
78
|
} from './query.js'
|
package/src/period.ts
CHANGED
|
@@ -13,11 +13,15 @@ export type PeriodPreset =
|
|
|
13
13
|
| 'lastMonth'
|
|
14
14
|
| 'thisYear'
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/** A range of days: what `resolvePeriod` turns into instants. */
|
|
17
|
+
export type PeriodRange =
|
|
17
18
|
| { last: { days: number } }
|
|
18
19
|
| { from: string; to: string }
|
|
19
20
|
| { preset: PeriodPreset }
|
|
20
21
|
|
|
22
|
+
/** A range of days, or `{ all: true }` for no time filter. */
|
|
23
|
+
export type PeriodInput = PeriodRange | { all: true }
|
|
24
|
+
|
|
21
25
|
export type ResolvedPeriod = {
|
|
22
26
|
/** First local day, `YYYY-MM-DD`. */
|
|
23
27
|
from: string
|
|
@@ -32,7 +36,9 @@ export type ResolvedPeriod = {
|
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
export const MAX_PERIOD_DAYS = 731
|
|
35
|
-
export const DEFAULT_PERIOD
|
|
39
|
+
export const DEFAULT_PERIOD = {
|
|
40
|
+
last: { days: 30 }
|
|
41
|
+
} as const satisfies PeriodInput
|
|
36
42
|
|
|
37
43
|
const DAY_MS = 86_400_000
|
|
38
44
|
|
|
@@ -140,7 +146,7 @@ export class PeriodError extends Error {
|
|
|
140
146
|
|
|
141
147
|
/** Resolves a period input to local days and instants in `timezone`. */
|
|
142
148
|
export function resolvePeriod(
|
|
143
|
-
input:
|
|
149
|
+
input: PeriodRange,
|
|
144
150
|
timezone: string,
|
|
145
151
|
now: Date
|
|
146
152
|
): ResolvedPeriod {
|
|
@@ -154,7 +160,7 @@ export function resolvePeriod(
|
|
|
154
160
|
return periodFromDays(from, to, timezone)
|
|
155
161
|
}
|
|
156
162
|
|
|
157
|
-
function periodDays(input:
|
|
163
|
+
function periodDays(input: PeriodRange, today: string): [string, string] {
|
|
158
164
|
if ('last' in input) return [addDays(today, -(input.last.days - 1)), today]
|
|
159
165
|
if ('from' in input) return [input.from, input.to]
|
|
160
166
|
|