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/analytics.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
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,
|
|
@@ -111,6 +116,16 @@ export type AnalyticsQueryEvent = {
|
|
|
111
116
|
error?: unknown
|
|
112
117
|
}
|
|
113
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
|
+
|
|
114
129
|
export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
|
|
115
130
|
datasets: DS
|
|
116
131
|
/** Databases by name; a dataset's `source` picks one, or the only one is used. */
|
|
@@ -129,6 +144,21 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
|
|
|
129
144
|
authorize?:
|
|
130
145
|
| ((dataset: DS[number], ctx: Ctx) => boolean | Promise<boolean>)
|
|
131
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
|
|
132
162
|
/** IANA timezone periods and time buckets resolve in. UTC when omitted. */
|
|
133
163
|
timezone?:
|
|
134
164
|
| ((
|
|
@@ -183,15 +213,15 @@ export type Analytics<DS extends readonly AnyDataset[], Ctx> = {
|
|
|
183
213
|
datasets?: readonly K[] | undefined
|
|
184
214
|
}): z.ZodType<DatasetQuery<Extract<DS[number], { key: K }>>>
|
|
185
215
|
/**
|
|
186
|
-
* The input schema for the datasets `
|
|
187
|
-
* 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.
|
|
188
218
|
*/
|
|
189
219
|
querySchema(options: {
|
|
190
220
|
ctx: Ctx
|
|
191
221
|
}): Promise<z.ZodType<DatasetQuery<DS[number]>>>
|
|
192
222
|
/** The result schema for one dataset. */
|
|
193
223
|
resultSchema(dataset: DS[number]['key']): z.ZodType<QueryResult<Row, Row>>
|
|
194
|
-
/** The datasets `
|
|
224
|
+
/** The datasets `ctx` may query, with the measures and dimensions it may use. */
|
|
195
225
|
listDatasets(ctx: Ctx): Promise<DatasetCatalogEntry<DS[number]['key']>[]>
|
|
196
226
|
}
|
|
197
227
|
|
|
@@ -203,6 +233,13 @@ export type AnalyticsResult<A, Q> =
|
|
|
203
233
|
|
|
204
234
|
type ShapedResult = QueryResult<Row, Row>
|
|
205
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
|
+
|
|
206
243
|
/** A parsed, authorized query with everything the hooks decide resolved. */
|
|
207
244
|
type Prepared = {
|
|
208
245
|
dataset: AnyDataset
|
|
@@ -222,6 +259,7 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
222
259
|
const sourceNames = Object.keys(options.sources)
|
|
223
260
|
const byKey = new Map<string, AnyDataset>()
|
|
224
261
|
const sourceOf = new Map<string, AnalyticsSource>()
|
|
262
|
+
const restrictedOf = new Map<string, RestrictedField[]>()
|
|
225
263
|
|
|
226
264
|
for (const dataset of datasets) {
|
|
227
265
|
if (byKey.has(dataset.key))
|
|
@@ -231,6 +269,14 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
231
269
|
const source = resolveSource(dataset, options.sources, sourceNames)
|
|
232
270
|
sourceOf.set(dataset.key, source)
|
|
233
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)
|
|
234
280
|
}
|
|
235
281
|
|
|
236
282
|
const now = options.now ?? (() => new Date())
|
|
@@ -250,12 +296,18 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
250
296
|
return source
|
|
251
297
|
}
|
|
252
298
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
.
|
|
257
|
-
|
|
258
|
-
|
|
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(',')
|
|
259
311
|
|
|
260
312
|
let schema = querySchemas.get(selectionKey)
|
|
261
313
|
if (!schema) {
|
|
@@ -274,14 +326,54 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
274
326
|
return schema
|
|
275
327
|
}
|
|
276
328
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
+
}
|
|
355
|
+
|
|
356
|
+
const allowed = await Promise.all(restricted.map(mayUse))
|
|
357
|
+
return restricted.filter((_, index) => !allowed[index])
|
|
358
|
+
}
|
|
280
359
|
|
|
281
|
-
|
|
282
|
-
|
|
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
|
+
})
|
|
283
375
|
)
|
|
284
|
-
return
|
|
376
|
+
return views.filter((view) => view !== null)
|
|
285
377
|
}
|
|
286
378
|
|
|
287
379
|
const querySchemaForCtx = async (ctx: Ctx) => {
|
|
@@ -289,12 +381,12 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
289
381
|
if (allowed.length === 0) {
|
|
290
382
|
throw new AnalyticsError('forbidden', 'No dataset is allowed')
|
|
291
383
|
}
|
|
292
|
-
return querySchemaFor(allowed
|
|
384
|
+
return querySchemaFor(allowed)
|
|
293
385
|
}
|
|
294
386
|
|
|
295
387
|
/** Parse, authorize, and resolve everything that comes from hooks. */
|
|
296
388
|
const prepare = async (input: unknown, ctx: Ctx): Promise<Prepared> => {
|
|
297
|
-
const parsed = querySchemaFor(
|
|
389
|
+
const parsed = querySchemaFor(datasets).safeParse(input)
|
|
298
390
|
if (!parsed.success) {
|
|
299
391
|
throw new AnalyticsError(
|
|
300
392
|
'invalid_query',
|
|
@@ -303,7 +395,8 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
303
395
|
)
|
|
304
396
|
}
|
|
305
397
|
|
|
306
|
-
const
|
|
398
|
+
const parsedQuery = parsed.data as ParsedQuery
|
|
399
|
+
const dataset = datasetOf(parsedQuery.dataset)
|
|
307
400
|
if (options.authorize && !(await options.authorize(dataset, ctx))) {
|
|
308
401
|
throw new AnalyticsError(
|
|
309
402
|
'forbidden',
|
|
@@ -311,6 +404,24 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
311
404
|
)
|
|
312
405
|
}
|
|
313
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
|
+
|
|
314
425
|
const tenant: unknown = options.tenant(ctx)
|
|
315
426
|
if (!isTenant(tenant)) {
|
|
316
427
|
throw new AnalyticsError(
|
|
@@ -318,6 +429,13 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
318
429
|
'Refusing to query without a tenant'
|
|
319
430
|
)
|
|
320
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
|
+
}
|
|
321
439
|
|
|
322
440
|
const timezone = (await options.timezone?.(ctx)) || 'UTC'
|
|
323
441
|
if (!isValidTimezone(timezone)) {
|
|
@@ -327,7 +445,7 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
327
445
|
)
|
|
328
446
|
}
|
|
329
447
|
|
|
330
|
-
const query = normalizeQuery(dataset,
|
|
448
|
+
const query = normalizeQuery(dataset, parsedQuery)
|
|
331
449
|
const at = now()
|
|
332
450
|
let period: ResolvedPeriod | null
|
|
333
451
|
try {
|
|
@@ -478,10 +596,9 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
|
|
|
478
596
|
) =>
|
|
479
597
|
schemaOptions && 'ctx' in schemaOptions
|
|
480
598
|
? querySchemaForCtx(schemaOptions.ctx)
|
|
481
|
-
: querySchemaFor(
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
>['querySchema'],
|
|
599
|
+
: querySchemaFor(
|
|
600
|
+
schemaOptions?.datasets?.map(datasetOf) ?? datasets
|
|
601
|
+
)) as Analytics<DS, Ctx>['querySchema'],
|
|
485
602
|
resultSchema: (key) => resultSchemaFor(key) as z.ZodType<ShapedResult>,
|
|
486
603
|
listDatasets: async (ctx) =>
|
|
487
604
|
(await allowedDatasets(ctx)).map(
|
|
@@ -552,7 +669,7 @@ function resolveSource(
|
|
|
552
669
|
function compileEverything(dataset: AnyDataset, dialect: Dialect): void {
|
|
553
670
|
const now = new Date(0)
|
|
554
671
|
const context: PlanContext = {
|
|
555
|
-
tenant: 'tenant',
|
|
672
|
+
tenant: dataset.tenantColumn ? 'tenant' : allTenants,
|
|
556
673
|
timezone: 'UTC',
|
|
557
674
|
period: resolvePeriod({ last: { days: 1 } }, 'UTC', now),
|
|
558
675
|
now,
|
|
@@ -664,6 +781,60 @@ function decodeKey(value: unknown, dialect: Dialect): string | number {
|
|
|
664
781
|
return dialect.decodeNumber(value) ?? String(value)
|
|
665
782
|
}
|
|
666
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
|
+
|
|
667
838
|
function catalogEntry(dataset: AnyDataset): DatasetCatalogEntry {
|
|
668
839
|
return {
|
|
669
840
|
key: dataset.key,
|