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/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 & { where?: Expr<'boolean'> | undefined }
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(options: Filtered): AggregateMeasure<'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(expr: Expr<'number'>, options: Filtered): AggregateMeasure<'number'> {
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(expr: Expr<'number'>, options: Filtered): AggregateMeasure<'number'> {
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(expr: Expr<'number'>, options: Filtered): AggregateMeasure<'number'> {
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<const A extends string, const B extends string>(
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
- > = Described & {
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<G extends boolean, F extends boolean, Key> = Omit<
194
- DimensionOptions<G, F>,
195
- 'label'
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<G extends boolean, F extends boolean> = {
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
- /** Narrows `groupable` and `filterable` (which `base` computed) to what the options say. */
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
- | { groupOnly?: G | undefined; filterOnly?: F | undefined }
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>> & DimensionAccess<G, F> {
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<G extends boolean = boolean, F extends boolean = boolean>(
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
- readonly tenantColumn: ExprNode
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
- /** The column every query is filtered by; the value comes from `tenant(ctx)`. */
589
- tenantColumn: Expr<SqlType>
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.node,
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 { type AnalyticsSource, createAnalytics } from './analytics.js'
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 { TenantId } from './plan.js'
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
- /** A tenant with seeded rows in the period. */
28
- tenant: string | number
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, and that day buckets
51
- * agree with periods in the timezone.
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: TenantId | readonly TenantId[] }) => 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: TenantId | readonly TenantId[] = options.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: `Tenant ${options.tenant} has no rows in the period; the checks below prove little`
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) {