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/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
|
-
/**
|
|
22
|
-
|
|
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,
|
|
34
|
-
*
|
|
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-
|
|
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:
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
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 {
|
|
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
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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 `
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
.
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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:
|
|
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(
|
|
477
|
-
|
|
478
|
-
|
|
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`
|
|
491
|
-
* when a JavaScript caller's hook returns something its type does not
|
|
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,
|