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 +82 -23
- package/dist/analytics.d.ts +16 -4
- package/dist/dataset.d.ts +50 -30
- package/dist/{index-wrr67sxq.js → index-4rmjas9h.js} +103 -16
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/testing.d.ts +9 -4
- package/dist/testing.js +10 -2
- package/package.json +1 -1
- package/src/analytics.ts +196 -25
- package/src/dataset.ts +135 -60
- package/src/index.ts +2 -0
- package/src/plan.ts +6 -0
- package/src/testing.ts +31 -9
package/src/dataset.ts
CHANGED
|
@@ -16,9 +16,20 @@ declare const ratioBrand: unique symbol
|
|
|
16
16
|
/** A share or ratio between 0 and 1. Branded so nobody renders 0.42 as "0.42%". */
|
|
17
17
|
export type Ratio = number & { readonly [ratioBrand]: 'zeroToOne' }
|
|
18
18
|
|
|
19
|
-
type Described = {
|
|
19
|
+
type Described<Access extends string = string> = {
|
|
20
20
|
label: string
|
|
21
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
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -37,6 +48,7 @@ export type AggregateMeasure<
|
|
|
37
48
|
readonly result: R
|
|
38
49
|
readonly label: string
|
|
39
50
|
readonly description: string | undefined
|
|
51
|
+
readonly access: string | undefined
|
|
40
52
|
readonly node: Extract<ExprNode, { kind: 'aggregate' }>
|
|
41
53
|
}
|
|
42
54
|
|
|
@@ -45,6 +57,7 @@ export type ShareMeasure = {
|
|
|
45
57
|
readonly result: 'ratio'
|
|
46
58
|
readonly label: string
|
|
47
59
|
readonly description: string | undefined
|
|
60
|
+
readonly access: string | undefined
|
|
48
61
|
readonly numerator: ExprNode
|
|
49
62
|
readonly denominator: ExprNode | null
|
|
50
63
|
}
|
|
@@ -57,6 +70,8 @@ export type RatioMeasure<
|
|
|
57
70
|
readonly result: 'ratio'
|
|
58
71
|
readonly label: string
|
|
59
72
|
readonly description: string | undefined
|
|
73
|
+
/** A ratio also needs the access of the measures it divides. */
|
|
74
|
+
readonly access: string | undefined
|
|
60
75
|
readonly numerator: A
|
|
61
76
|
readonly denominator: B
|
|
62
77
|
}
|
|
@@ -70,19 +85,22 @@ export type MeasureValue<M> = M extends { result: 'count' }
|
|
|
70
85
|
? number | null
|
|
71
86
|
: Ratio | null
|
|
72
87
|
|
|
73
|
-
type Filtered = Described & {
|
|
88
|
+
type Filtered<Access extends string> = Described<Access> & {
|
|
89
|
+
where?: Expr<'boolean'> | undefined
|
|
90
|
+
}
|
|
74
91
|
|
|
75
|
-
function aggregate<R extends 'count' | 'number'>(
|
|
92
|
+
function aggregate<R extends 'count' | 'number', Access extends string>(
|
|
76
93
|
result: R,
|
|
77
94
|
fn: Extract<ExprNode, { kind: 'aggregate' }>['fn'],
|
|
78
95
|
arg: Expr<SqlType> | null,
|
|
79
|
-
options: Filtered
|
|
80
|
-
): AggregateMeasure<R> {
|
|
96
|
+
options: Filtered<Access>
|
|
97
|
+
): AggregateMeasure<R> & Marked<NoInfer<Access>> {
|
|
81
98
|
return {
|
|
82
99
|
kind: 'aggregate',
|
|
83
100
|
result,
|
|
84
101
|
label: options.label,
|
|
85
102
|
description: options.description,
|
|
103
|
+
access: options.access,
|
|
86
104
|
node: {
|
|
87
105
|
kind: 'aggregate',
|
|
88
106
|
fn,
|
|
@@ -94,35 +112,46 @@ function aggregate<R extends 'count' | 'number'>(
|
|
|
94
112
|
|
|
95
113
|
export const measure = {
|
|
96
114
|
/** Number of rows, optionally only those matching `where`. */
|
|
97
|
-
count
|
|
115
|
+
count<Access extends string = never>(
|
|
116
|
+
options: Filtered<Access>
|
|
117
|
+
): AggregateMeasure<'count'> & Marked<NoInfer<Access>> {
|
|
98
118
|
return aggregate('count', 'count', null, options)
|
|
99
119
|
},
|
|
100
120
|
|
|
101
121
|
/** Number of rows matching `condition`. */
|
|
102
|
-
countWhere(
|
|
122
|
+
countWhere<Access extends string = never>(
|
|
103
123
|
condition: Expr<'boolean'>,
|
|
104
|
-
options: Described
|
|
105
|
-
): AggregateMeasure<'count'> {
|
|
124
|
+
options: Described<Access>
|
|
125
|
+
): AggregateMeasure<'count'> & Marked<NoInfer<Access>> {
|
|
106
126
|
return aggregate('count', 'count', null, { ...options, where: condition })
|
|
107
127
|
},
|
|
108
128
|
|
|
109
129
|
/** Number of distinct non-null values of `expr`. */
|
|
110
|
-
countDistinct(
|
|
130
|
+
countDistinct<Access extends string = never>(
|
|
111
131
|
expr: Expr<SqlType>,
|
|
112
|
-
options: Filtered
|
|
113
|
-
): AggregateMeasure<'count'> {
|
|
132
|
+
options: Filtered<Access>
|
|
133
|
+
): AggregateMeasure<'count'> & Marked<NoInfer<Access>> {
|
|
114
134
|
return aggregate('count', 'countDistinct', expr, options)
|
|
115
135
|
},
|
|
116
136
|
|
|
117
|
-
sum
|
|
137
|
+
sum<Access extends string = never>(
|
|
138
|
+
expr: Expr<'number'>,
|
|
139
|
+
options: Filtered<Access>
|
|
140
|
+
): AggregateMeasure<'number'> & Marked<NoInfer<Access>> {
|
|
118
141
|
return aggregate('number', 'sum', expr, options)
|
|
119
142
|
},
|
|
120
143
|
|
|
121
|
-
avg
|
|
144
|
+
avg<Access extends string = never>(
|
|
145
|
+
expr: Expr<'number'>,
|
|
146
|
+
options: Filtered<Access>
|
|
147
|
+
): AggregateMeasure<'number'> & Marked<NoInfer<Access>> {
|
|
122
148
|
return aggregate('number', 'avg', expr, options)
|
|
123
149
|
},
|
|
124
150
|
|
|
125
|
-
median
|
|
151
|
+
median<Access extends string = never>(
|
|
152
|
+
expr: Expr<'number'>,
|
|
153
|
+
options: Filtered<Access>
|
|
154
|
+
): AggregateMeasure<'number'> & Marked<NoInfer<Access>> {
|
|
126
155
|
return aggregate('number', 'median', expr, options)
|
|
127
156
|
},
|
|
128
157
|
|
|
@@ -130,17 +159,18 @@ export const measure = {
|
|
|
130
159
|
* The share of rows matching `numerator` among rows matching `denominator` (all rows when
|
|
131
160
|
* omitted). A `Ratio` from 0 to 1.
|
|
132
161
|
*/
|
|
133
|
-
share(
|
|
134
|
-
options: Described & {
|
|
162
|
+
share<Access extends string = never>(
|
|
163
|
+
options: Described<Access> & {
|
|
135
164
|
numerator: Expr<'boolean'>
|
|
136
165
|
denominator?: Expr<'boolean'> | undefined
|
|
137
166
|
}
|
|
138
|
-
): ShareMeasure {
|
|
167
|
+
): ShareMeasure & Marked<NoInfer<Access>> {
|
|
139
168
|
return {
|
|
140
169
|
kind: 'share',
|
|
141
170
|
result: 'ratio',
|
|
142
171
|
label: options.label,
|
|
143
172
|
description: options.description,
|
|
173
|
+
access: options.access,
|
|
144
174
|
numerator: options.numerator.node,
|
|
145
175
|
denominator: options.denominator?.node ?? null
|
|
146
176
|
}
|
|
@@ -150,16 +180,21 @@ export const measure = {
|
|
|
150
180
|
* One measure of this dataset over another, computed after aggregation. Both keys must be
|
|
151
181
|
* measures of the same dataset; `defineDataset` checks them.
|
|
152
182
|
*/
|
|
153
|
-
ratio<
|
|
183
|
+
ratio<
|
|
184
|
+
const A extends string,
|
|
185
|
+
const B extends string,
|
|
186
|
+
Access extends string = never
|
|
187
|
+
>(
|
|
154
188
|
numerator: A,
|
|
155
189
|
denominator: B,
|
|
156
|
-
options: Described
|
|
157
|
-
): RatioMeasure<A, B> {
|
|
190
|
+
options: Described<Access>
|
|
191
|
+
): RatioMeasure<A, B> & Marked<NoInfer<Access>> {
|
|
158
192
|
return {
|
|
159
193
|
kind: 'ratio',
|
|
160
194
|
result: 'ratio',
|
|
161
195
|
label: options.label,
|
|
162
196
|
description: options.description,
|
|
197
|
+
access: options.access,
|
|
163
198
|
numerator,
|
|
164
199
|
denominator
|
|
165
200
|
}
|
|
@@ -181,8 +216,9 @@ export type DimensionCategory = 'closed' | 'open' | 'numeric' | 'time'
|
|
|
181
216
|
|
|
182
217
|
type DimensionOptions<
|
|
183
218
|
G extends boolean = boolean,
|
|
184
|
-
F extends boolean = boolean
|
|
185
|
-
|
|
219
|
+
F extends boolean = boolean,
|
|
220
|
+
Access extends string = string
|
|
221
|
+
> = Described<Access> & {
|
|
186
222
|
/** Only offered for grouping. */
|
|
187
223
|
groupOnly?: G | undefined
|
|
188
224
|
/** Only offered for filtering. */
|
|
@@ -190,19 +226,25 @@ type DimensionOptions<
|
|
|
190
226
|
}
|
|
191
227
|
|
|
192
228
|
/** Time dimensions name themselves after their unit unless given a label. */
|
|
193
|
-
type TimeDimensionOptions<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
229
|
+
type TimeDimensionOptions<
|
|
230
|
+
G extends boolean,
|
|
231
|
+
F extends boolean,
|
|
232
|
+
Key,
|
|
233
|
+
Access extends string
|
|
234
|
+
> = Omit<DimensionOptions<G, F, Access>, 'label'> & {
|
|
197
235
|
label?: string | undefined
|
|
198
236
|
labelFor?: ((key: Key) => string) | undefined
|
|
199
237
|
}
|
|
200
238
|
|
|
201
239
|
/**
|
|
202
240
|
* `groupOnly: true` makes `filterable` the literal `false` (and `filterOnly`, `groupable`), so
|
|
203
|
-
* 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.
|
|
204
242
|
*/
|
|
205
|
-
export type DimensionAccess<
|
|
243
|
+
export type DimensionAccess<
|
|
244
|
+
G extends boolean,
|
|
245
|
+
F extends boolean,
|
|
246
|
+
Access extends string = never
|
|
247
|
+
> = Marked<Access> & {
|
|
206
248
|
readonly groupable: [F] extends [true] ? false : boolean
|
|
207
249
|
readonly filterable: [G] extends [true] ? false : boolean
|
|
208
250
|
}
|
|
@@ -211,6 +253,7 @@ type DimensionBase<C extends DimensionCategory, Key> = {
|
|
|
211
253
|
readonly category: C
|
|
212
254
|
readonly label: string
|
|
213
255
|
readonly description: string | undefined
|
|
256
|
+
readonly access: string | undefined
|
|
214
257
|
readonly groupable: boolean
|
|
215
258
|
readonly filterable: boolean
|
|
216
259
|
/** Phantom: the key type in result rows. */
|
|
@@ -317,23 +360,32 @@ function base(options: DimensionOptions) {
|
|
|
317
360
|
return {
|
|
318
361
|
label: options.label,
|
|
319
362
|
description: options.description,
|
|
363
|
+
access: options.access,
|
|
320
364
|
groupable: options.filterOnly !== true,
|
|
321
365
|
filterable: options.groupOnly !== true
|
|
322
366
|
}
|
|
323
367
|
}
|
|
324
368
|
|
|
325
|
-
/**
|
|
369
|
+
/**
|
|
370
|
+
* Narrows `groupable`, `filterable` and `access` (which `base` copied) to what the options
|
|
371
|
+
* say.
|
|
372
|
+
*/
|
|
326
373
|
function withAccess<
|
|
327
374
|
D extends AnyDimension,
|
|
328
375
|
G extends boolean,
|
|
329
|
-
F extends boolean
|
|
376
|
+
F extends boolean,
|
|
377
|
+
Access extends string
|
|
330
378
|
>(
|
|
331
379
|
_options:
|
|
332
|
-
| {
|
|
380
|
+
| {
|
|
381
|
+
groupOnly?: G | undefined
|
|
382
|
+
filterOnly?: F | undefined
|
|
383
|
+
access?: Access | undefined
|
|
384
|
+
}
|
|
333
385
|
| undefined,
|
|
334
386
|
dimension: D
|
|
335
|
-
): D & DimensionAccess<G, F
|
|
336
|
-
return dimension as D & DimensionAccess<G, F>
|
|
387
|
+
): D & DimensionAccess<G, F, NoInfer<Access>> {
|
|
388
|
+
return dimension as D & DimensionAccess<G, F, Access>
|
|
337
389
|
}
|
|
338
390
|
|
|
339
391
|
export const dimension = {
|
|
@@ -346,13 +398,15 @@ export const dimension = {
|
|
|
346
398
|
const E extends Record<string, string>,
|
|
347
399
|
V extends string | null,
|
|
348
400
|
G extends boolean = boolean,
|
|
349
|
-
F extends boolean = boolean
|
|
401
|
+
F extends boolean = boolean,
|
|
402
|
+
Access extends string = never
|
|
350
403
|
>(
|
|
351
404
|
expr: Expr<'string', V> & CoversValues<V, E[keyof E]>,
|
|
352
405
|
values: E,
|
|
353
|
-
options: DimensionOptions<G, F> &
|
|
406
|
+
options: DimensionOptions<G, F, Access> &
|
|
354
407
|
EmptyOption & { labels: Record<E[keyof E], string> }
|
|
355
|
-
): EnumDimension<E[keyof E], EmptyOf<V>> &
|
|
408
|
+
): EnumDimension<E[keyof E], EmptyOf<V>> &
|
|
409
|
+
DimensionAccess<G, F, NoInfer<Access>> {
|
|
356
410
|
for (const value of Object.values(values)) {
|
|
357
411
|
if (!(value in options.labels)) {
|
|
358
412
|
throw new Error(`${options.label}: the value ${value} has no label`)
|
|
@@ -379,14 +433,15 @@ export const dimension = {
|
|
|
379
433
|
V extends string | null,
|
|
380
434
|
const K extends string,
|
|
381
435
|
G extends boolean = boolean,
|
|
382
|
-
F extends boolean = boolean
|
|
436
|
+
F extends boolean = boolean,
|
|
437
|
+
Access extends string = never
|
|
383
438
|
>(
|
|
384
|
-
options: DimensionOptions<G, F> &
|
|
439
|
+
options: DimensionOptions<G, F, Access> &
|
|
385
440
|
EmptyOption & {
|
|
386
441
|
expr: Expr<'string', V> & CoversValues<V, NoInfer<K>>
|
|
387
442
|
values: Record<K, string>
|
|
388
443
|
}
|
|
389
|
-
): EnumDimension<K, EmptyOf<V>> & DimensionAccess<G, F
|
|
444
|
+
): EnumDimension<K, EmptyOf<V>> & DimensionAccess<G, F, NoInfer<Access>> {
|
|
390
445
|
return withAccess(options, {
|
|
391
446
|
...base(options),
|
|
392
447
|
category: 'closed',
|
|
@@ -405,14 +460,15 @@ export const dimension = {
|
|
|
405
460
|
relation<
|
|
406
461
|
Key extends string | number,
|
|
407
462
|
G extends boolean = boolean,
|
|
408
|
-
F extends boolean = boolean
|
|
463
|
+
F extends boolean = boolean,
|
|
464
|
+
Access extends string = never
|
|
409
465
|
>(
|
|
410
|
-
options: DimensionOptions<G, F> & {
|
|
466
|
+
options: DimensionOptions<G, F, Access> & {
|
|
411
467
|
key: Expr<Key extends number ? 'number' : 'string', Key | null>
|
|
412
468
|
name: Expr<'string'>
|
|
413
469
|
empty?: string | undefined
|
|
414
470
|
}
|
|
415
|
-
): RelationDimension<Key> & DimensionAccess<G, F
|
|
471
|
+
): RelationDimension<Key> & DimensionAccess<G, F, NoInfer<Access>> {
|
|
416
472
|
return withAccess(options, {
|
|
417
473
|
...base(options),
|
|
418
474
|
category: 'open',
|
|
@@ -434,9 +490,10 @@ export const dimension = {
|
|
|
434
490
|
manyToMany<
|
|
435
491
|
Row = unknown,
|
|
436
492
|
G extends boolean = boolean,
|
|
437
|
-
F extends boolean = boolean
|
|
493
|
+
F extends boolean = boolean,
|
|
494
|
+
Access extends string = never
|
|
438
495
|
>(
|
|
439
|
-
options: DimensionOptions<G, F> & {
|
|
496
|
+
options: DimensionOptions<G, F, Access> & {
|
|
440
497
|
through: {
|
|
441
498
|
table: Table<Row>
|
|
442
499
|
on: Expr<'boolean'>
|
|
@@ -447,7 +504,7 @@ export const dimension = {
|
|
|
447
504
|
note?: string | undefined
|
|
448
505
|
empty?: string | undefined
|
|
449
506
|
}
|
|
450
|
-
): ManyToManyDimension & DimensionAccess<G, F
|
|
507
|
+
): ManyToManyDimension & DimensionAccess<G, F, NoInfer<Access>> {
|
|
451
508
|
return withAccess(options, {
|
|
452
509
|
...base(options),
|
|
453
510
|
category: 'open',
|
|
@@ -462,12 +519,16 @@ export const dimension = {
|
|
|
462
519
|
},
|
|
463
520
|
|
|
464
521
|
/** A numeric column, grouped by its exact values and filtered with `between`. */
|
|
465
|
-
number<
|
|
522
|
+
number<
|
|
523
|
+
G extends boolean = boolean,
|
|
524
|
+
F extends boolean = boolean,
|
|
525
|
+
Access extends string = never
|
|
526
|
+
>(
|
|
466
527
|
expr: Expr<'number'>,
|
|
467
|
-
options: DimensionOptions<G, F> & {
|
|
528
|
+
options: DimensionOptions<G, F, Access> & {
|
|
468
529
|
labelFor?: ((key: number) => string) | undefined
|
|
469
530
|
}
|
|
470
|
-
): NumberDimension & DimensionAccess<G, F
|
|
531
|
+
): NumberDimension & DimensionAccess<G, F, NoInfer<Access>> {
|
|
471
532
|
return withAccess(options, {
|
|
472
533
|
...base(options),
|
|
473
534
|
category: 'numeric',
|
|
@@ -481,12 +542,13 @@ export const dimension = {
|
|
|
481
542
|
timeBucket<
|
|
482
543
|
const T extends string,
|
|
483
544
|
G extends boolean = boolean,
|
|
484
|
-
F extends boolean = boolean
|
|
545
|
+
F extends boolean = boolean,
|
|
546
|
+
Access extends string = never
|
|
485
547
|
>(
|
|
486
548
|
time: T,
|
|
487
549
|
unit: TimeBucketUnit,
|
|
488
|
-
options?: TimeDimensionOptions<G, F, string>
|
|
489
|
-
): TimeBucketDimension<T> & DimensionAccess<G, F
|
|
550
|
+
options?: TimeDimensionOptions<G, F, string, Access>
|
|
551
|
+
): TimeBucketDimension<T> & DimensionAccess<G, F, NoInfer<Access>> {
|
|
490
552
|
return withAccess(options, {
|
|
491
553
|
...base({
|
|
492
554
|
...options,
|
|
@@ -504,12 +566,13 @@ export const dimension = {
|
|
|
504
566
|
timePart<
|
|
505
567
|
const T extends string,
|
|
506
568
|
G extends boolean = boolean,
|
|
507
|
-
F extends boolean = boolean
|
|
569
|
+
F extends boolean = boolean,
|
|
570
|
+
Access extends string = never
|
|
508
571
|
>(
|
|
509
572
|
time: T,
|
|
510
573
|
part: TimePartName,
|
|
511
|
-
options?: TimeDimensionOptions<G, F, number>
|
|
512
|
-
): TimePartDimension<T> & DimensionAccess<G, F
|
|
574
|
+
options?: TimeDimensionOptions<G, F, number, Access>
|
|
575
|
+
): TimePartDimension<T> & DimensionAccess<G, F, NoInfer<Access>> {
|
|
513
576
|
return withAccess(options, {
|
|
514
577
|
...base({
|
|
515
578
|
...options,
|
|
@@ -541,7 +604,8 @@ export type Dataset<
|
|
|
541
604
|
readonly description: string
|
|
542
605
|
readonly from: TableRef
|
|
543
606
|
readonly source: string | undefined
|
|
544
|
-
|
|
607
|
+
/** Null for a dataset without tenants, which only `allTenants` may query. */
|
|
608
|
+
readonly tenantColumn: ExprNode | null
|
|
545
609
|
readonly scope: ExprNode | null
|
|
546
610
|
readonly time: Readonly<Record<T, TimeField>>
|
|
547
611
|
readonly defaultTime: T
|
|
@@ -585,8 +649,12 @@ export type DatasetDefinition<
|
|
|
585
649
|
from: { readonly ref: TableRef }
|
|
586
650
|
/** Which source (database) the dataset runs on; the default when there is one source. */
|
|
587
651
|
source?: string | undefined
|
|
588
|
-
/**
|
|
589
|
-
|
|
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
|
|
590
658
|
/** Always applied, for example excluding test rows. */
|
|
591
659
|
scope?: Expr<'boolean'> | undefined
|
|
592
660
|
time: Record<T, { column: Expr<'timestamp'>; label: string }>
|
|
@@ -623,6 +691,13 @@ export function defineDataset<
|
|
|
623
691
|
)
|
|
624
692
|
}
|
|
625
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
|
+
|
|
626
701
|
const time = Object.fromEntries(
|
|
627
702
|
timeEntries.map(([key, field]) => [
|
|
628
703
|
key,
|
|
@@ -636,7 +711,7 @@ export function defineDataset<
|
|
|
636
711
|
description: definition.description,
|
|
637
712
|
from: definition.from.ref,
|
|
638
713
|
source: definition.source,
|
|
639
|
-
tenantColumn: definition.tenantColumn
|
|
714
|
+
tenantColumn: definition.tenantColumn?.node ?? null,
|
|
640
715
|
scope: definition.scope?.node ?? null,
|
|
641
716
|
time,
|
|
642
717
|
defaultTime: definition.defaultTime ?? (firstTime[0] as T),
|
|
@@ -675,7 +750,7 @@ function validateDataset<DS extends AnyDataset>(dataset: DS): DS {
|
|
|
675
750
|
})
|
|
676
751
|
}
|
|
677
752
|
|
|
678
|
-
check(dataset.tenantColumn, 'tenantColumn')
|
|
753
|
+
if (dataset.tenantColumn) check(dataset.tenantColumn, 'tenantColumn')
|
|
679
754
|
if (dataset.scope) check(dataset.scope, 'scope')
|
|
680
755
|
for (const [key, field] of Object.entries<TimeField>(dataset.time)) {
|
|
681
756
|
check(field.column, `time field ${key}`)
|
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 {
|
package/src/plan.ts
CHANGED
|
@@ -216,6 +216,12 @@ function baseWhere(
|
|
|
216
216
|
): ExprNode[] {
|
|
217
217
|
const where: ExprNode[] = []
|
|
218
218
|
if (tenant !== allTenants) {
|
|
219
|
+
// `analytics.query` refuses this first; the planner fails closed on its own too.
|
|
220
|
+
if (!dataset.tenantColumn) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`Dataset ${dataset.key} has no tenant column; only allTenants may query it`
|
|
223
|
+
)
|
|
224
|
+
}
|
|
219
225
|
const ids = typeof tenant === 'object' ? [...new Set(tenant)] : [tenant]
|
|
220
226
|
const [only] = ids
|
|
221
227
|
// One id compiles exactly like the scalar; an empty list renders as a false condition.
|
package/src/testing.ts
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* Framework-free: `checkDatasetContract` returns a report and `assertDatasetContract` throws
|
|
4
4
|
* with every failure listed, so it works from `bun:test`, vitest or a script.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type AnalyticsSource,
|
|
8
|
+
createAnalytics,
|
|
9
|
+
isAnalyticsError
|
|
10
|
+
} from './analytics.js'
|
|
7
11
|
import {
|
|
8
12
|
type AnyDataset,
|
|
9
13
|
type AnyDimension,
|
|
@@ -11,7 +15,7 @@ import {
|
|
|
11
15
|
measure
|
|
12
16
|
} from './dataset.js'
|
|
13
17
|
import type { PeriodInput } from './period.js'
|
|
14
|
-
import type
|
|
18
|
+
import { type Tenant, type TenantId, allTenants } from './plan.js'
|
|
15
19
|
import { MAX_MEASURES } from './query.js'
|
|
16
20
|
|
|
17
21
|
export type ContractCheck = {
|
|
@@ -24,8 +28,11 @@ export type ContractReport = { ok: boolean; checks: ContractCheck[] }
|
|
|
24
28
|
export type ContractOptions = {
|
|
25
29
|
dataset: AnyDataset
|
|
26
30
|
source: AnalyticsSource
|
|
27
|
-
/**
|
|
28
|
-
|
|
31
|
+
/**
|
|
32
|
+
* A tenant with seeded rows in the period. `allTenants` for a dataset without a tenant
|
|
33
|
+
* column, which no single tenant may query.
|
|
34
|
+
*/
|
|
35
|
+
tenant: TenantId | typeof allTenants
|
|
29
36
|
/** A tenant with no rows; defaults to a sentinel that no real tenant uses. */
|
|
30
37
|
emptyTenant?: string | number
|
|
31
38
|
/** A timezone far from UTC makes bucketing mistakes visible. Defaults to Pacific/Auckland. */
|
|
@@ -47,8 +54,9 @@ type ProbeResult = {
|
|
|
47
54
|
|
|
48
55
|
/**
|
|
49
56
|
* Checks that every measure, grouping and filter compiles and runs, that groups add up to the
|
|
50
|
-
* total, that labels resolve, that another tenant's rows never count,
|
|
51
|
-
*
|
|
57
|
+
* total, that labels resolve, that another tenant's rows never count (or, for a dataset
|
|
58
|
+
* without a tenant column, that a scoped caller is refused), and that day buckets agree with
|
|
59
|
+
* periods in the timezone. Fields marked with `access` are checked like any other.
|
|
52
60
|
*/
|
|
53
61
|
export async function checkDatasetContract(
|
|
54
62
|
options: ContractOptions
|
|
@@ -79,14 +87,15 @@ export async function checkDatasetContract(
|
|
|
79
87
|
const analytics = createAnalytics({
|
|
80
88
|
datasets: [probe],
|
|
81
89
|
sources: { main: options.source },
|
|
82
|
-
tenant: (ctx: { tenant:
|
|
90
|
+
tenant: (ctx: { tenant: Tenant }) => ctx.tenant,
|
|
91
|
+
authorizeField: () => true,
|
|
83
92
|
timezone: () => timezone,
|
|
84
93
|
...(options.now && { now: options.now })
|
|
85
94
|
})
|
|
86
95
|
|
|
87
96
|
const run = async (
|
|
88
97
|
query: Record<string, unknown>,
|
|
89
|
-
tenant:
|
|
98
|
+
tenant: Tenant = options.tenant
|
|
90
99
|
): Promise<ProbeResult> => {
|
|
91
100
|
const input = { dataset: dataset.key, period, limit: 100, ...query }
|
|
92
101
|
return (await analytics.query(input as never, {
|
|
@@ -113,10 +122,12 @@ export async function checkDatasetContract(
|
|
|
113
122
|
|
|
114
123
|
const total = (await run({ measures: [COUNT] })).totals[COUNT] ?? 0
|
|
115
124
|
if (total === 0) {
|
|
125
|
+
const scope =
|
|
126
|
+
options.tenant === allTenants ? 'The dataset' : `Tenant ${options.tenant}`
|
|
116
127
|
checks.push({
|
|
117
128
|
name: 'seeded rows',
|
|
118
129
|
ok: false,
|
|
119
|
-
message:
|
|
130
|
+
message: `${scope} has no rows in the period; the checks below prove little`
|
|
120
131
|
})
|
|
121
132
|
}
|
|
122
133
|
|
|
@@ -211,6 +222,17 @@ export async function checkDatasetContract(
|
|
|
211
222
|
['an empty tenant list', []]
|
|
212
223
|
] as const) {
|
|
213
224
|
await check(`tenant isolation: ${name}`, async () => {
|
|
225
|
+
// Rows without a tenant cannot be isolated; a scoped caller must be refused instead.
|
|
226
|
+
if (!dataset.tenantColumn) {
|
|
227
|
+
const refused = await run({ measures: [COUNT] }, tenant).then(
|
|
228
|
+
() => false,
|
|
229
|
+
(error: unknown) => isAnalyticsError(error, 'forbidden')
|
|
230
|
+
)
|
|
231
|
+
return refused
|
|
232
|
+
? undefined
|
|
233
|
+
: `${name} may query a dataset without a tenant column`
|
|
234
|
+
}
|
|
235
|
+
|
|
214
236
|
const measures = Object.keys(probe.measures)
|
|
215
237
|
const leaked: [string, number | null][] = []
|
|
216
238
|
for (let start = 0; start < measures.length; start += MAX_MEASURES) {
|