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/dist/testing.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import { type AnalyticsSource } from './analytics.js';
7
7
  import { type AnyDataset } from './dataset.js';
8
8
  import type { PeriodInput } from './period.js';
9
+ import { type TenantId, allTenants } from './plan.js';
9
10
  export type ContractCheck = {
10
11
  name: string;
11
12
  ok: boolean;
@@ -18,8 +19,11 @@ export type ContractReport = {
18
19
  export type ContractOptions = {
19
20
  dataset: AnyDataset;
20
21
  source: AnalyticsSource;
21
- /** A tenant with seeded rows in the period. */
22
- tenant: string | number;
22
+ /**
23
+ * A tenant with seeded rows in the period. `allTenants` for a dataset without a tenant
24
+ * column, which no single tenant may query.
25
+ */
26
+ tenant: TenantId | typeof allTenants;
23
27
  /** A tenant with no rows; defaults to a sentinel that no real tenant uses. */
24
28
  emptyTenant?: string | number;
25
29
  /** A timezone far from UTC makes bucketing mistakes visible. Defaults to Pacific/Auckland. */
@@ -30,8 +34,9 @@ export type ContractOptions = {
30
34
  };
31
35
  /**
32
36
  * Checks that every measure, grouping and filter compiles and runs, that groups add up to the
33
- * total, that labels resolve, that another tenant's rows never count, and that day buckets
34
- * agree with periods in the timezone.
37
+ * total, that labels resolve, that another tenant's rows never count (or, for a dataset
38
+ * without a tenant column, that a scoped caller is refused), and that day buckets agree with
39
+ * periods in the timezone. Fields marked with `access` are checked like any other.
35
40
  */
36
41
  export declare function checkDatasetContract(options: ContractOptions): Promise<ContractReport>;
37
42
  /** Runs `checkDatasetContract` and throws with every failed check listed. */
package/dist/testing.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import {
2
2
  MAX_MEASURES,
3
+ allTenants2,
4
+ isAnalyticsError2,
3
5
  createAnalytics2,
4
6
  measure2,
5
7
  dimension2
6
- } from "./index-dj3369zz.js";
8
+ } from "./index-4rmjas9h.js";
7
9
 
8
10
  // src/testing.ts
9
11
  var COUNT = "__contractCount";
@@ -31,6 +33,7 @@ async function checkDatasetContract(options) {
31
33
  datasets: [probe],
32
34
  sources: { main: options.source },
33
35
  tenant: (ctx) => ctx.tenant,
36
+ authorizeField: () => true,
34
37
  timezone: () => timezone,
35
38
  ...options.now && { now: options.now }
36
39
  });
@@ -55,10 +58,11 @@ async function checkDatasetContract(options) {
55
58
  };
56
59
  const total = (await run({ measures: [COUNT] })).totals[COUNT] ?? 0;
57
60
  if (total === 0) {
61
+ const scope = options.tenant === allTenants2 ? "The dataset" : `Tenant ${options.tenant}`;
58
62
  checks.push({
59
63
  name: "seeded rows",
60
64
  ok: false,
61
- message: `Tenant ${options.tenant} has no rows in the period; the checks below prove little`
65
+ message: `${scope} has no rows in the period; the checks below prove little`
62
66
  });
63
67
  }
64
68
  for (const key of Object.keys(dataset.measures)) {
@@ -128,26 +132,35 @@ async function checkDatasetContract(options) {
128
132
  });
129
133
  }
130
134
  }
131
- await check("tenant isolation", async () => {
132
- const measures = Object.keys(probe.measures);
133
- const leaked = [];
134
- for (let start = 0;start < measures.length; start += MAX_MEASURES) {
135
- const batch = measures.slice(start, start + MAX_MEASURES);
136
- const other = await run({ measures: batch }, emptyTenant);
137
- leaked.push(...Object.entries(other.totals).filter(([, value]) => value !== 0 && value !== null));
138
- }
139
- if (leaked.length > 0) {
140
- return `an empty tenant sees ${leaked.map(([key, value]) => `${key}=${value}`).join(", ")}`;
141
- }
142
- for (const [key, entry] of dimensions) {
143
- if (!entry.groupable)
144
- continue;
145
- const grouped = await run({ measures: [COUNT], groupBy: [key] }, emptyTenant);
146
- if (grouped.rows.length > 0)
147
- return `an empty tenant sees ${grouped.rows.length} ${key} groups`;
148
- }
149
- return;
150
- });
135
+ for (const [name, tenant] of [
136
+ ["an empty tenant", emptyTenant],
137
+ ["an empty tenant list", []]
138
+ ]) {
139
+ await check(`tenant isolation: ${name}`, async () => {
140
+ if (!dataset.tenantColumn) {
141
+ const refused = await run({ measures: [COUNT] }, tenant).then(() => false, (error) => isAnalyticsError2(error, "forbidden"));
142
+ return refused ? undefined : `${name} may query a dataset without a tenant column`;
143
+ }
144
+ const measures = Object.keys(probe.measures);
145
+ const leaked = [];
146
+ for (let start = 0;start < measures.length; start += MAX_MEASURES) {
147
+ const batch = measures.slice(start, start + MAX_MEASURES);
148
+ const result = await run({ measures: batch }, tenant);
149
+ leaked.push(...Object.entries(result.totals).filter(([, value]) => value !== 0 && value !== null));
150
+ }
151
+ if (leaked.length > 0) {
152
+ return `${name} sees ${leaked.map(([key, value]) => `${key}=${value}`).join(", ")}`;
153
+ }
154
+ for (const [key, entry] of dimensions) {
155
+ if (!entry.groupable)
156
+ continue;
157
+ const grouped = await run({ measures: [COUNT], groupBy: [key] }, tenant);
158
+ if (grouped.rows.length > 0)
159
+ return `${name} sees ${grouped.rows.length} ${key} groups`;
160
+ }
161
+ return;
162
+ });
163
+ }
151
164
  for (const time of Object.keys(dataset.time)) {
152
165
  await check(`day buckets of ${time} in ${timezone}`, async () => {
153
166
  const days = await run({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "measurr",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "A small typed semantic layer: datasets of measures and dimensions compiled to one SQL statement per dialect.",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
package/src/analytics.ts CHANGED
@@ -1,11 +1,17 @@
1
1
  import { z } from 'zod'
2
2
  import type { CompiledStatement, Dialect, SelectStatement } from './compile.js'
3
- import type { AnyDataset, AnyDimension } from './dataset.js'
3
+ import type {
4
+ AnyDataset,
5
+ AnyDimension,
6
+ AnyMeasure,
7
+ Dataset
8
+ } from './dataset.js'
4
9
  import {
5
10
  type NormalizedQuery,
6
11
  type ParsedQuery,
7
12
  type PlanContext,
8
13
  type Tenant,
14
+ type TenantId,
9
15
  allTenants,
10
16
  normalizeQuery,
11
17
  planLookup,
@@ -110,14 +116,25 @@ export type AnalyticsQueryEvent = {
110
116
  error?: unknown
111
117
  }
112
118
 
119
+ /** The measure and dimension keys of a dataset (of any dataset, for a union). */
120
+ export type FieldKey<D> =
121
+ D extends Dataset<string, infer M, infer Dm> ? keyof (M & Dm) & string : never
122
+
123
+ /** The access keys a dataset's measures and dimensions are marked with. */
124
+ export type AccessKey<D> =
125
+ D extends Dataset<string, infer M, infer Dm>
126
+ ? NonNullable<(M & Dm)[keyof (M & Dm)]['access']>
127
+ : never
128
+
113
129
  export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
114
130
  datasets: DS
115
131
  /** Databases by name; a dataset's `source` picks one, or the only one is used. */
116
132
  sources: Record<string, AnalyticsSource>
117
133
  /**
118
- * The tenant every query is scoped to. Queries without one (null, undefined, '') are
119
- * refused. Return `allTenants` to drop the tenant filter, only for callers allowed to see
120
- * every tenant's rows.
134
+ * The tenant every query is scoped to, or a list of them (an empty list matches no rows).
135
+ * Queries without one (null, undefined, '', a list holding one of those) are refused.
136
+ * Return `allTenants` to drop the tenant filter, only for callers allowed to see every
137
+ * tenant's rows.
121
138
  */
122
139
  tenant: (ctx: Ctx) => Tenant | null | undefined
123
140
  /**
@@ -127,6 +144,21 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
127
144
  authorize?:
128
145
  | ((dataset: DS[number], ctx: Ctx) => boolean | Promise<boolean>)
129
146
  | undefined
147
+ /**
148
+ * Whether `ctx` may use the measure or dimension `key` of `dataset`, which is marked with
149
+ * `access`. Called only for marked fields, once per access key: a ratio also needs the
150
+ * access of the measures it divides. Required when any field is marked. It decides which
151
+ * fields `listDatasets(ctx)` and `querySchema({ ctx })` offer, and is checked again on every
152
+ * query. `access` is the union of the keys the datasets use, so a new key shows up here.
153
+ */
154
+ authorizeField?:
155
+ | ((
156
+ dataset: DS[number],
157
+ key: FieldKey<DS[number]>,
158
+ access: AccessKey<DS[number]>,
159
+ ctx: Ctx
160
+ ) => boolean | Promise<boolean>)
161
+ | undefined
130
162
  /** IANA timezone periods and time buckets resolve in. UTC when omitted. */
131
163
  timezone?:
132
164
  | ((
@@ -181,15 +213,15 @@ export type Analytics<DS extends readonly AnyDataset[], Ctx> = {
181
213
  datasets?: readonly K[] | undefined
182
214
  }): z.ZodType<DatasetQuery<Extract<DS[number], { key: K }>>>
183
215
  /**
184
- * The input schema for the datasets `authorize` allows `ctx`, for a tool built per caller.
185
- * Rejects with `forbidden` when none are allowed.
216
+ * The input schema for the datasets `ctx` may query, without the fields `authorizeField`
217
+ * refuses it, for a tool built per caller. Rejects with `forbidden` when none are allowed.
186
218
  */
187
219
  querySchema(options: {
188
220
  ctx: Ctx
189
221
  }): Promise<z.ZodType<DatasetQuery<DS[number]>>>
190
222
  /** The result schema for one dataset. */
191
223
  resultSchema(dataset: DS[number]['key']): z.ZodType<QueryResult<Row, Row>>
192
- /** The datasets `authorize` allows `ctx`, with their measures and dimensions. */
224
+ /** The datasets `ctx` may query, with the measures and dimensions it may use. */
193
225
  listDatasets(ctx: Ctx): Promise<DatasetCatalogEntry<DS[number]['key']>[]>
194
226
  }
195
227
 
@@ -201,13 +233,21 @@ export type AnalyticsResult<A, Q> =
201
233
 
202
234
  type ShapedResult = QueryResult<Row, Row>
203
235
 
236
+ /** A measure or dimension marked with `access`, with every access key it needs. */
237
+ type RestrictedField = {
238
+ kind: 'measure' | 'dimension'
239
+ key: string
240
+ access: readonly string[]
241
+ }
242
+
204
243
  /** A parsed, authorized query with everything the hooks decide resolved. */
205
244
  type Prepared = {
206
245
  dataset: AnyDataset
207
246
  query: NormalizedQuery
208
247
  tenant: Tenant
209
248
  timezone: string
210
- period: ResolvedPeriod
249
+ /** Null for all time. */
250
+ period: ResolvedPeriod | null
211
251
  previous: ResolvedPeriod | null
212
252
  now: Date
213
253
  }
@@ -219,6 +259,7 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
219
259
  const sourceNames = Object.keys(options.sources)
220
260
  const byKey = new Map<string, AnyDataset>()
221
261
  const sourceOf = new Map<string, AnalyticsSource>()
262
+ const restrictedOf = new Map<string, RestrictedField[]>()
222
263
 
223
264
  for (const dataset of datasets) {
224
265
  if (byKey.has(dataset.key))
@@ -228,6 +269,14 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
228
269
  const source = resolveSource(dataset, options.sources, sourceNames)
229
270
  sourceOf.set(dataset.key, source)
230
271
  compileEverything(dataset, source.dialect)
272
+
273
+ const restricted = restrictedFields(dataset)
274
+ if (restricted.length > 0 && !options.authorizeField) {
275
+ throw new Error(
276
+ `Dataset ${dataset.key} marks fields with access, but createAnalytics has no authorizeField`
277
+ )
278
+ }
279
+ restrictedOf.set(dataset.key, restricted)
231
280
  }
232
281
 
233
282
  const now = options.now ?? (() => new Date())
@@ -247,12 +296,18 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
247
296
  return source
248
297
  }
249
298
 
250
- const querySchemaFor = (keys: readonly string[] | undefined) => {
251
- const selected = keys ? keys.map(datasetOf) : datasets
252
- const selectionKey = selected
253
- .map((dataset) => dataset.key)
254
- .sort()
255
- .join(',')
299
+ /** One schema per set of datasets (or views of them without some fields). */
300
+ const querySchemaFor = (selected: readonly AnyDataset[]) => {
301
+ const keyOf = (dataset: AnyDataset) => {
302
+ if (byKey.get(dataset.key) === dataset) return dataset.key
303
+
304
+ const fields = [
305
+ ...Object.keys(dataset.measures),
306
+ ...Object.keys(dataset.dimensions)
307
+ ]
308
+ return `${dataset.key}(${fields.join(' ')})`
309
+ }
310
+ const selectionKey = selected.map(keyOf).sort().join(',')
256
311
 
257
312
  let schema = querySchemas.get(selectionKey)
258
313
  if (!schema) {
@@ -271,14 +326,54 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
271
326
  return schema
272
327
  }
273
328
 
274
- const allowedDatasets = async (ctx: Ctx) => {
275
- const { authorize } = options
276
- if (!authorize) return datasets
329
+ /** The marked fields of `dataset` that `ctx` may not use. */
330
+ const deniedFields = async (
331
+ dataset: AnyDataset,
332
+ ctx: Ctx
333
+ ): Promise<RestrictedField[]> => {
334
+ const restricted = restrictedOf.get(dataset.key) ?? []
335
+ // Keys and access marks are read from the datasets themselves, so they fit the hook.
336
+ const authorizeField = options.authorizeField as
337
+ | ((
338
+ dataset: AnyDataset,
339
+ key: string,
340
+ access: string,
341
+ ctx: Ctx
342
+ ) => boolean | Promise<boolean>)
343
+ | undefined
344
+ // createAnalytics refuses marked fields without the hook; deny them all regardless.
345
+ if (restricted.length === 0 || !authorizeField) return restricted
346
+
347
+ const mayUse = async (field: RestrictedField) => {
348
+ const answers = await Promise.all(
349
+ field.access.map((access) =>
350
+ authorizeField(dataset, field.key, access, ctx)
351
+ )
352
+ )
353
+ return answers.every(Boolean)
354
+ }
277
355
 
278
- const allowed = await Promise.all(
279
- datasets.map((dataset) => authorize(dataset, ctx))
356
+ const allowed = await Promise.all(restricted.map(mayUse))
357
+ return restricted.filter((_, index) => !allowed[index])
358
+ }
359
+
360
+ /**
361
+ * The datasets `ctx` may query, each without the fields it may not use. A dataset without
362
+ * a tenant column needs `allTenants`, and one without a usable measure is left out.
363
+ */
364
+ const allowedDatasets = async (ctx: Ctx): Promise<AnyDataset[]> => {
365
+ const everyTenant = options.tenant(ctx) === allTenants
366
+ const views = await Promise.all(
367
+ datasets.map(async (dataset) => {
368
+ if (!dataset.tenantColumn && !everyTenant) return null
369
+ if (options.authorize && !(await options.authorize(dataset, ctx)))
370
+ return null
371
+
372
+ const view = withoutFields(dataset, await deniedFields(dataset, ctx))
373
+ return Object.keys(view.measures).length > 0 ? view : null
374
+ })
280
375
  )
281
- return datasets.filter((_, index) => allowed[index])
376
+ return views.filter((view) => view !== null)
282
377
  }
283
378
 
284
379
  const querySchemaForCtx = async (ctx: Ctx) => {
@@ -286,12 +381,12 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
286
381
  if (allowed.length === 0) {
287
382
  throw new AnalyticsError('forbidden', 'No dataset is allowed')
288
383
  }
289
- return querySchemaFor(allowed.map((dataset) => dataset.key))
384
+ return querySchemaFor(allowed)
290
385
  }
291
386
 
292
387
  /** Parse, authorize, and resolve everything that comes from hooks. */
293
388
  const prepare = async (input: unknown, ctx: Ctx): Promise<Prepared> => {
294
- const parsed = querySchemaFor(undefined).safeParse(input)
389
+ const parsed = querySchemaFor(datasets).safeParse(input)
295
390
  if (!parsed.success) {
296
391
  throw new AnalyticsError(
297
392
  'invalid_query',
@@ -300,7 +395,8 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
300
395
  )
301
396
  }
302
397
 
303
- const dataset = datasetOf((parsed.data as ParsedQuery).dataset)
398
+ const parsedQuery = parsed.data as ParsedQuery
399
+ const dataset = datasetOf(parsedQuery.dataset)
304
400
  if (options.authorize && !(await options.authorize(dataset, ctx))) {
305
401
  throw new AnalyticsError(
306
402
  'forbidden',
@@ -308,6 +404,24 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
308
404
  )
309
405
  }
310
406
 
407
+ // Sorting names only asked measures and grouped dimensions, so these cover it.
408
+ const dimensionsUsed = [
409
+ ...(parsedQuery.groupBy ?? []),
410
+ ...(parsedQuery.filters ?? []).map((filter) => filter.dimension)
411
+ ]
412
+ const refused = (await deniedFields(dataset, ctx)).filter((field) =>
413
+ field.kind === 'measure'
414
+ ? parsedQuery.measures.includes(field.key)
415
+ : dimensionsUsed.includes(field.key)
416
+ )
417
+ if (refused.length > 0) {
418
+ const names = refused.map((field) => `${field.kind} ${field.key}`)
419
+ throw new AnalyticsError(
420
+ 'forbidden',
421
+ `Not allowed to use ${names.join(', ')} of ${dataset.key}`
422
+ )
423
+ }
424
+
311
425
  const tenant: unknown = options.tenant(ctx)
312
426
  if (!isTenant(tenant)) {
313
427
  throw new AnalyticsError(
@@ -315,6 +429,13 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
315
429
  'Refusing to query without a tenant'
316
430
  )
317
431
  }
432
+ // Never drop a narrower scope: rows without a tenant are only for every-tenant callers.
433
+ if (!dataset.tenantColumn && tenant !== allTenants) {
434
+ throw new AnalyticsError(
435
+ 'forbidden',
436
+ `${dataset.key} has no tenant column; only a caller allowed to see every tenant may query it`
437
+ )
438
+ }
318
439
 
319
440
  const timezone = (await options.timezone?.(ctx)) || 'UTC'
320
441
  if (!isValidTimezone(timezone)) {
@@ -324,18 +445,21 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
324
445
  )
325
446
  }
326
447
 
327
- const query = normalizeQuery(dataset, parsed.data as ParsedQuery)
448
+ const query = normalizeQuery(dataset, parsedQuery)
328
449
  const at = now()
329
- let period: ResolvedPeriod
450
+ let period: ResolvedPeriod | null
330
451
  try {
331
- period = resolvePeriod(query.period, timezone, at)
452
+ period =
453
+ 'all' in query.period ? null : resolvePeriod(query.period, timezone, at)
332
454
  } catch (error) {
333
455
  if (error instanceof PeriodError) {
334
456
  throw new AnalyticsError('invalid_period', error.message)
335
457
  }
336
458
  throw error
337
459
  }
338
- const previous = query.compareToPrevious ? previousPeriod(period) : null
460
+ // The schema refuses compareToPrevious with an all-time period.
461
+ const previous =
462
+ query.compareToPrevious && period ? previousPeriod(period) : null
339
463
 
340
464
  return { dataset, query, tenant, timezone, period, previous, now: at }
341
465
  }
@@ -357,7 +481,7 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
357
481
  dialect
358
482
  )
359
483
 
360
- const runPeriod = async (target: ResolvedPeriod) => {
484
+ const runPeriod = async (target: ResolvedPeriod | null) => {
361
485
  const statements = periodStatements(dataset, query, {
362
486
  tenant,
363
487
  timezone,
@@ -373,27 +497,26 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
373
497
  const { rows, truncated } = shapeRows(dataset, query, rawRows, dialect)
374
498
  const totalsRow = (rawTotals ?? rawRows)[0] ?? {}
375
499
  const totals = shapeMeasures(dataset, query.measures, totalsRow, dialect)
376
- const periodOut = { from: target.from, to: target.to, timezone }
377
500
 
378
- return { period: periodOut, rows, totals, truncated }
501
+ return { rows, totals, truncated }
379
502
  }
380
503
 
504
+ const range = ({ from, to }: ResolvedPeriod) => ({ from, to, timezone })
381
505
  const [current, before] = await Promise.all([
382
506
  runPeriod(period),
383
- previous ? runPeriod(previous) : null
507
+ previous &&
508
+ runPeriod(previous).then(({ rows, totals }) => ({
509
+ period: range(previous),
510
+ rows,
511
+ totals
512
+ }))
384
513
  ])
385
514
 
386
515
  return {
387
- period: current.period,
516
+ period: period ? range(period) : { all: true, timezone },
388
517
  rows: current.rows,
389
518
  totals: current.totals,
390
- ...(before && {
391
- previous: {
392
- period: before.period,
393
- rows: before.rows,
394
- totals: before.totals
395
- }
396
- }),
519
+ ...(before && { previous: before }),
397
520
  notes: resultNotes(dataset, query),
398
521
  truncated: current.truncated
399
522
  }
@@ -473,10 +596,9 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
473
596
  ) =>
474
597
  schemaOptions && 'ctx' in schemaOptions
475
598
  ? querySchemaForCtx(schemaOptions.ctx)
476
- : querySchemaFor(schemaOptions?.datasets)) as Analytics<
477
- DS,
478
- Ctx
479
- >['querySchema'],
599
+ : querySchemaFor(
600
+ schemaOptions?.datasets?.map(datasetOf) ?? datasets
601
+ )) as Analytics<DS, Ctx>['querySchema'],
480
602
  resultSchema: (key) => resultSchemaFor(key) as z.ZodType<ShapedResult>,
481
603
  listDatasets: async (ctx) =>
482
604
  (await allowedDatasets(ctx)).map(
@@ -487,12 +609,20 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
487
609
  }
488
610
 
489
611
  /**
490
- * Fails closed: only a non-empty string, a finite number or `allTenants` scopes a query, even
491
- * when a JavaScript caller's hook returns something its type does not allow.
612
+ * Fails closed: only a non-empty string, a finite number, a list of those or `allTenants`
613
+ * scopes a query, even when a JavaScript caller's hook returns something its type does not
614
+ * allow.
492
615
  */
493
616
  function isTenant(value: unknown): value is Tenant {
494
617
  return (
495
618
  value === allTenants ||
619
+ isTenantId(value) ||
620
+ (Array.isArray(value) && value.every(isTenantId))
621
+ )
622
+ }
623
+
624
+ function isTenantId(value: unknown): value is TenantId {
625
+ return (
496
626
  (typeof value === 'string' && value !== '') ||
497
627
  (typeof value === 'number' && Number.isFinite(value))
498
628
  )
@@ -539,7 +669,7 @@ function resolveSource(
539
669
  function compileEverything(dataset: AnyDataset, dialect: Dialect): void {
540
670
  const now = new Date(0)
541
671
  const context: PlanContext = {
542
- tenant: 'tenant',
672
+ tenant: dataset.tenantColumn ? 'tenant' : allTenants,
543
673
  timezone: 'UTC',
544
674
  period: resolvePeriod({ last: { days: 1 } }, 'UTC', now),
545
675
  now,
@@ -651,6 +781,60 @@ function decodeKey(value: unknown, dialect: Dialect): string | number {
651
781
  return dialect.decodeNumber(value) ?? String(value)
652
782
  }
653
783
 
784
+ /**
785
+ * Every marked field of `dataset` with the access keys it needs: its own, and for a ratio
786
+ * those of the measures it divides, which it would leak otherwise.
787
+ */
788
+ function restrictedFields(dataset: AnyDataset): RestrictedField[] {
789
+ const measures: Record<string, AnyMeasure> = dataset.measures
790
+ // defineDataset refuses ratio cycles.
791
+ const accessOf = (key: string): string[] => {
792
+ const entry = measures[key]
793
+ if (!entry) return []
794
+ const own = entry.access === undefined ? [] : [entry.access]
795
+ return entry.kind === 'ratio'
796
+ ? [...own, ...accessOf(entry.numerator), ...accessOf(entry.denominator)]
797
+ : own
798
+ }
799
+
800
+ return [
801
+ ...Object.keys(measures).map((key) => ({
802
+ kind: 'measure' as const,
803
+ key,
804
+ access: [...new Set(accessOf(key))]
805
+ })),
806
+ ...Object.entries<AnyDimension>(dataset.dimensions).map(([key, entry]) => ({
807
+ kind: 'dimension' as const,
808
+ key,
809
+ access: entry.access === undefined ? [] : [entry.access]
810
+ }))
811
+ ].filter((field) => field.access.length > 0)
812
+ }
813
+
814
+ /** `dataset` without the given fields; the dataset itself when there are none. */
815
+ function withoutFields(
816
+ dataset: AnyDataset,
817
+ fields: readonly RestrictedField[]
818
+ ): AnyDataset {
819
+ if (fields.length === 0) return dataset
820
+
821
+ const keep = <Field>(
822
+ kind: RestrictedField['kind'],
823
+ entries: Record<string, Field>
824
+ ): Record<string, Field> =>
825
+ Object.fromEntries(
826
+ Object.entries(entries).filter(
827
+ ([key]) =>
828
+ !fields.some((field) => field.kind === kind && field.key === key)
829
+ )
830
+ )
831
+ return {
832
+ ...dataset,
833
+ measures: keep('measure', dataset.measures),
834
+ dimensions: keep('dimension', dataset.dimensions)
835
+ }
836
+ }
837
+
654
838
  function catalogEntry(dataset: AnyDataset): DatasetCatalogEntry {
655
839
  return {
656
840
  key: dataset.key,