measurr 0.1.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.
@@ -0,0 +1,663 @@
1
+ import { z } from 'zod'
2
+ import type { CompiledStatement, Dialect, SelectStatement } from './compile.js'
3
+ import type { AnyDataset, AnyDimension } from './dataset.js'
4
+ import {
5
+ type NormalizedQuery,
6
+ type ParsedQuery,
7
+ type PlanContext,
8
+ normalizeQuery,
9
+ planLookup,
10
+ planQuery,
11
+ resultNotes,
12
+ shapeMeasures,
13
+ shapeRows
14
+ } from './plan.js'
15
+ import {
16
+ PeriodError,
17
+ type ResolvedPeriod,
18
+ isValidTimezone,
19
+ previousPeriod,
20
+ resolvePeriod
21
+ } from './period.js'
22
+ import {
23
+ type DatasetQuery,
24
+ type QueryResult,
25
+ type ResultOf,
26
+ buildQuerySchema,
27
+ datasetResultSchema
28
+ } from './query.js'
29
+
30
+ export type Row = Record<string, unknown>
31
+
32
+ /** A database: a dialect to compile with and a function that runs the compiled SQL. */
33
+ export type AnalyticsSource = {
34
+ readonly dialect: Dialect
35
+ /** Runs one read-only statement and returns its rows as plain objects. */
36
+ execute(statement: CompiledStatement): Promise<readonly Row[]>
37
+ }
38
+
39
+ export type AnalyticsErrorCode =
40
+ | 'invalid_query'
41
+ | 'forbidden'
42
+ | 'missing_tenant'
43
+ | 'invalid_timezone'
44
+ | 'invalid_period'
45
+ | 'unknown_value'
46
+
47
+ /** What `details` holds for each error code. */
48
+ export type AnalyticsErrorDetails = {
49
+ /** The schema's issues, for a model to fix its query. */
50
+ invalid_query: readonly z.core.$ZodIssue[]
51
+ forbidden: undefined
52
+ missing_tenant: undefined
53
+ invalid_timezone: undefined
54
+ invalid_period: undefined
55
+ /** The values that matched nothing, and the values that exist. */
56
+ unknown_value: {
57
+ dimension: string
58
+ unknown: string[]
59
+ valid: { key: string; label: string }[]
60
+ }
61
+ }
62
+
63
+ export class AnalyticsError<
64
+ C extends AnalyticsErrorCode = AnalyticsErrorCode
65
+ > extends Error {
66
+ override readonly name = 'AnalyticsError'
67
+ readonly details: AnalyticsErrorDetails[C]
68
+
69
+ constructor(
70
+ readonly code: C,
71
+ message: string,
72
+ ...[details]: AnalyticsErrorDetails[C] extends undefined
73
+ ? []
74
+ : [details: AnalyticsErrorDetails[C]]
75
+ ) {
76
+ super(message)
77
+ this.details = details as AnalyticsErrorDetails[C]
78
+ }
79
+ }
80
+
81
+ /** One `AnalyticsError` type per code, so checking `code` narrows `details`. */
82
+ export type AnalyticsErrorOf<
83
+ C extends AnalyticsErrorCode = AnalyticsErrorCode
84
+ > = C extends AnalyticsErrorCode ? AnalyticsError<C> : never
85
+
86
+ /**
87
+ * Whether `error` is an `AnalyticsError` (with `code`, when given). The result narrows by
88
+ * code: `if (isAnalyticsError(error, 'unknown_value')) error.details.valid`.
89
+ */
90
+ export function isAnalyticsError<
91
+ C extends AnalyticsErrorCode = AnalyticsErrorCode
92
+ >(error: unknown, code?: C): error is AnalyticsErrorOf<C> {
93
+ return (
94
+ error instanceof AnalyticsError &&
95
+ (code === undefined || error.code === code)
96
+ )
97
+ }
98
+
99
+ /** What `onQuery` hears about each `analytics.query` call. */
100
+ export type AnalyticsQueryEvent = {
101
+ /** The dataset asked for; null when the input named no known dataset. */
102
+ dataset: string | null
103
+ durationMs: number
104
+ /** Rows returned; 0 on error. */
105
+ rows: number
106
+ outcome: 'success' | 'error'
107
+ /** What the query threw, on error. */
108
+ error?: unknown
109
+ }
110
+
111
+ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
112
+ datasets: DS
113
+ /** Databases by name; a dataset's `source` picks one, or the only one is used. */
114
+ sources: Record<string, AnalyticsSource>
115
+ /** The tenant every query is scoped to. Queries without one are refused. */
116
+ tenant: (ctx: Ctx) => string | number | null | undefined
117
+ /**
118
+ * Whether `ctx` may query `dataset`. Everything is allowed when omitted. It decides what
119
+ * `listDatasets(ctx)` and `querySchema({ ctx })` offer, and is checked again on every query.
120
+ */
121
+ authorize?:
122
+ | ((dataset: DS[number], ctx: Ctx) => boolean | Promise<boolean>)
123
+ | undefined
124
+ /** IANA timezone periods and time buckets resolve in. UTC when omitted. */
125
+ timezone?:
126
+ | ((
127
+ ctx: Ctx
128
+ ) => string | null | undefined | Promise<string | null | undefined>)
129
+ | undefined
130
+ /** The clock periods and `minutesAgo` resolve against. */
131
+ now?: (() => Date) | undefined
132
+ /**
133
+ * Called once per `query`, after it succeeds or fails: for metrics and logs. What it
134
+ * throws or rejects with is swallowed, so it can never fail a query.
135
+ */
136
+ onQuery?: ((event: AnalyticsQueryEvent, ctx: Ctx) => unknown) | undefined
137
+ }
138
+
139
+ export type DatasetCatalogEntry<Key extends string = string> = {
140
+ key: Key
141
+ label: string
142
+ description: string
143
+ time: { key: string; label: string }[]
144
+ measures: { key: string; label: string; description: string | undefined }[]
145
+ dimensions: {
146
+ key: string
147
+ label: string
148
+ description: string | undefined
149
+ kind: AnyDimension['kind']
150
+ category: AnyDimension['category']
151
+ groupable: boolean
152
+ filterable: boolean
153
+ values: { key: string; label: string }[] | undefined
154
+ }[]
155
+ }
156
+
157
+ export type Analytics<DS extends readonly AnyDataset[], Ctx> = {
158
+ readonly datasets: DS
159
+ /** Parses, authorizes, plans, compiles, runs and shapes one query. */
160
+ query<const Q extends DatasetQuery<DS[number]>>(
161
+ input: Q,
162
+ ctx: Ctx
163
+ ): Promise<ResultOf<DS[number], Q>>
164
+ /** The compiled statements a query would run, without running them or resolving labels. */
165
+ explain(
166
+ input: DatasetQuery<DS[number]>,
167
+ ctx: Ctx
168
+ ): Promise<CompiledStatement[]>
169
+ /**
170
+ * The input schema: a discriminated union on `dataset`, for the datasets listed (all when
171
+ * omitted), typed for those datasets only. One schema is built per set of datasets and
172
+ * reused.
173
+ */
174
+ querySchema<const K extends DS[number]['key'] = DS[number]['key']>(options?: {
175
+ datasets?: readonly K[] | undefined
176
+ }): z.ZodType<DatasetQuery<Extract<DS[number], { key: K }>>>
177
+ /**
178
+ * The input schema for the datasets `authorize` allows `ctx`, for a tool built per caller.
179
+ * Rejects with `forbidden` when none are allowed.
180
+ */
181
+ querySchema(options: {
182
+ ctx: Ctx
183
+ }): Promise<z.ZodType<DatasetQuery<DS[number]>>>
184
+ /** The result schema for one dataset. */
185
+ resultSchema(dataset: DS[number]['key']): z.ZodType<QueryResult<Row, Row>>
186
+ /** The datasets `authorize` allows `ctx`, with their measures and dimensions. */
187
+ listDatasets(ctx: Ctx): Promise<DatasetCatalogEntry<DS[number]['key']>[]>
188
+ }
189
+
190
+ export type AnalyticsQuery<A> =
191
+ A extends Analytics<infer DS, never> ? DatasetQuery<DS[number]> : never
192
+
193
+ export type AnalyticsResult<A, Q> =
194
+ A extends Analytics<infer DS, never> ? ResultOf<DS[number], Q> : never
195
+
196
+ type ShapedResult = QueryResult<Row, Row>
197
+
198
+ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
199
+ options: AnalyticsOptions<DS, Ctx>
200
+ ): Analytics<DS, Ctx> {
201
+ const datasets: readonly AnyDataset[] = options.datasets
202
+ const sourceNames = Object.keys(options.sources)
203
+ const byKey = new Map<string, AnyDataset>()
204
+ const sourceOf = new Map<string, AnalyticsSource>()
205
+
206
+ for (const dataset of datasets) {
207
+ if (byKey.has(dataset.key))
208
+ throw new Error(`Two datasets use the key ${dataset.key}`)
209
+ byKey.set(dataset.key, dataset)
210
+
211
+ const source = resolveSource(dataset, options.sources, sourceNames)
212
+ sourceOf.set(dataset.key, source)
213
+ compileEverything(dataset, source.dialect)
214
+ }
215
+
216
+ const now = options.now ?? (() => new Date())
217
+ const querySchemas = new Map<string, z.ZodType>()
218
+ const resultSchemas = new Map<string, z.ZodType>()
219
+
220
+ const datasetOf = (key: string): AnyDataset => {
221
+ const dataset = byKey.get(key)
222
+ if (!dataset)
223
+ throw new AnalyticsError('invalid_query', `Unknown dataset ${key}`, [])
224
+ return dataset
225
+ }
226
+
227
+ const sourceFor = (dataset: AnyDataset) => {
228
+ const source = sourceOf.get(dataset.key)
229
+ if (!source) throw new Error(`No source for ${dataset.key}`)
230
+ return source
231
+ }
232
+
233
+ const querySchemaFor = (keys: readonly string[] | undefined) => {
234
+ const selected = keys ? keys.map(datasetOf) : datasets
235
+ const selectionKey = selected
236
+ .map((dataset) => dataset.key)
237
+ .sort()
238
+ .join(',')
239
+
240
+ let schema = querySchemas.get(selectionKey)
241
+ if (!schema) {
242
+ schema = buildQuerySchema(selected)
243
+ querySchemas.set(selectionKey, schema)
244
+ }
245
+ return schema
246
+ }
247
+
248
+ const resultSchemaFor = (key: string) => {
249
+ let schema = resultSchemas.get(key)
250
+ if (!schema) {
251
+ schema = datasetResultSchema(datasetOf(key))
252
+ resultSchemas.set(key, schema)
253
+ }
254
+ return schema
255
+ }
256
+
257
+ const allowedDatasets = async (ctx: Ctx) => {
258
+ const { authorize } = options
259
+ if (!authorize) return datasets
260
+
261
+ const allowed = await Promise.all(
262
+ datasets.map((dataset) => authorize(dataset, ctx))
263
+ )
264
+ return datasets.filter((_, index) => allowed[index])
265
+ }
266
+
267
+ const querySchemaForCtx = async (ctx: Ctx) => {
268
+ const allowed = await allowedDatasets(ctx)
269
+ if (allowed.length === 0) {
270
+ throw new AnalyticsError('forbidden', 'No dataset is allowed')
271
+ }
272
+ return querySchemaFor(allowed.map((dataset) => dataset.key))
273
+ }
274
+
275
+ /** Parse, authorize, and resolve everything that comes from hooks. */
276
+ const prepare = async (input: unknown, ctx: Ctx) => {
277
+ const parsed = querySchemaFor(undefined).safeParse(input)
278
+ if (!parsed.success) {
279
+ throw new AnalyticsError(
280
+ 'invalid_query',
281
+ `Invalid analytics query: ${z.prettifyError(parsed.error)}`,
282
+ parsed.error.issues
283
+ )
284
+ }
285
+
286
+ const dataset = datasetOf((parsed.data as ParsedQuery).dataset)
287
+ if (options.authorize && !(await options.authorize(dataset, ctx))) {
288
+ throw new AnalyticsError(
289
+ 'forbidden',
290
+ `Not allowed to query ${dataset.key}`
291
+ )
292
+ }
293
+
294
+ const tenant = options.tenant(ctx)
295
+ if (tenant === null || tenant === undefined || tenant === '') {
296
+ throw new AnalyticsError(
297
+ 'missing_tenant',
298
+ 'Refusing to query without a tenant'
299
+ )
300
+ }
301
+
302
+ const timezone = (await options.timezone?.(ctx)) || 'UTC'
303
+ if (!isValidTimezone(timezone)) {
304
+ throw new AnalyticsError(
305
+ 'invalid_timezone',
306
+ `Unknown timezone ${timezone}`
307
+ )
308
+ }
309
+
310
+ const query = normalizeQuery(dataset, parsed.data as ParsedQuery)
311
+ const at = now()
312
+ let period: ResolvedPeriod
313
+ try {
314
+ period = resolvePeriod(query.period, timezone, at)
315
+ } catch (error) {
316
+ if (error instanceof PeriodError) {
317
+ throw new AnalyticsError('invalid_period', error.message)
318
+ }
319
+ throw error
320
+ }
321
+ const previous = query.compareToPrevious ? previousPeriod(period) : null
322
+
323
+ return { dataset, query, tenant, timezone, period, previous, now: at }
324
+ }
325
+
326
+ const runQuery = async (input: unknown, ctx: Ctx): Promise<ShapedResult> => {
327
+ const { dataset, query, tenant, timezone, period, previous, now } =
328
+ await prepare(input, ctx)
329
+ const source = sourceFor(dataset)
330
+ const { dialect } = source
331
+ const run = (statement: SelectStatement) =>
332
+ source.execute(dialect.compile(statement))
333
+
334
+ const resolvedValues = await resolveOpenValues(
335
+ dataset,
336
+ query,
337
+ tenant,
338
+ now,
339
+ run,
340
+ dialect
341
+ )
342
+
343
+ const runPeriod = async (target: ResolvedPeriod) => {
344
+ const statements = periodStatements(dataset, query, {
345
+ tenant,
346
+ timezone,
347
+ period: target,
348
+ now,
349
+ resolvedValues
350
+ })
351
+ const [rawRows, rawTotals] = await Promise.all([
352
+ run(statements.rows),
353
+ statements.totals ? run(statements.totals) : null
354
+ ])
355
+
356
+ const { rows, truncated } = shapeRows(dataset, query, rawRows, dialect)
357
+ const totalsRow = (rawTotals ?? rawRows)[0] ?? {}
358
+ const totals = shapeMeasures(dataset, query.measures, totalsRow, dialect)
359
+ const periodOut = { from: target.from, to: target.to, timezone }
360
+
361
+ return { period: periodOut, rows, totals, truncated }
362
+ }
363
+
364
+ const [current, before] = await Promise.all([
365
+ runPeriod(period),
366
+ previous ? runPeriod(previous) : null
367
+ ])
368
+
369
+ return {
370
+ period: current.period,
371
+ rows: current.rows,
372
+ totals: current.totals,
373
+ ...(before && {
374
+ previous: {
375
+ period: before.period,
376
+ rows: before.rows,
377
+ totals: before.totals
378
+ }
379
+ }),
380
+ notes: resultNotes(dataset, query),
381
+ truncated: current.truncated
382
+ }
383
+ }
384
+
385
+ const datasetKeyOf = (input: unknown) => {
386
+ if (typeof input !== 'object' || input === null) return null
387
+ const key = 'dataset' in input ? input.dataset : undefined
388
+ return typeof key === 'string' && byKey.has(key) ? key : null
389
+ }
390
+
391
+ const report = (event: AnalyticsQueryEvent, ctx: Ctx) => {
392
+ try {
393
+ // Promise.resolve covers async hooks, which reject instead of throwing.
394
+ Promise.resolve(options.onQuery?.(event, ctx)).catch(() => {})
395
+ } catch {
396
+ // Metrics and logs must never fail the query they describe.
397
+ }
398
+ }
399
+
400
+ const query = async (input: unknown, ctx: Ctx): Promise<ShapedResult> => {
401
+ const startedAt = performance.now()
402
+ const dataset = datasetKeyOf(input)
403
+ const durationMs = () => Math.round(performance.now() - startedAt)
404
+
405
+ try {
406
+ const result = await runQuery(input, ctx)
407
+ report(
408
+ {
409
+ dataset,
410
+ durationMs: durationMs(),
411
+ rows: result.rows.length,
412
+ outcome: 'success'
413
+ },
414
+ ctx
415
+ )
416
+ return result
417
+ } catch (error) {
418
+ report(
419
+ { dataset, durationMs: durationMs(), rows: 0, outcome: 'error', error },
420
+ ctx
421
+ )
422
+ throw error
423
+ }
424
+ }
425
+
426
+ const explain = async (
427
+ input: unknown,
428
+ ctx: Ctx
429
+ ): Promise<CompiledStatement[]> => {
430
+ const { dataset, query, tenant, timezone, period, previous, now } =
431
+ await prepare(input, ctx)
432
+ const { dialect } = sourceFor(dataset)
433
+ const compiled: CompiledStatement[] = []
434
+
435
+ for (const target of previous ? [period, previous] : [period]) {
436
+ const statements = periodStatements(dataset, query, {
437
+ tenant,
438
+ timezone,
439
+ period: target,
440
+ now,
441
+ resolvedValues: new Map()
442
+ })
443
+ compiled.push(dialect.compile(statements.rows))
444
+ if (statements.totals) compiled.push(dialect.compile(statements.totals))
445
+ }
446
+
447
+ return compiled
448
+ }
449
+
450
+ return {
451
+ datasets: options.datasets,
452
+ query: query as Analytics<DS, Ctx>['query'],
453
+ explain,
454
+ querySchema: ((
455
+ schemaOptions?: { datasets?: readonly string[] } | { ctx: Ctx }
456
+ ) =>
457
+ schemaOptions && 'ctx' in schemaOptions
458
+ ? querySchemaForCtx(schemaOptions.ctx)
459
+ : querySchemaFor(schemaOptions?.datasets)) as Analytics<
460
+ DS,
461
+ Ctx
462
+ >['querySchema'],
463
+ resultSchema: (key) => resultSchemaFor(key) as z.ZodType<ShapedResult>,
464
+ listDatasets: async (ctx) =>
465
+ (await allowedDatasets(ctx)).map(
466
+ (dataset) =>
467
+ catalogEntry(dataset) as DatasetCatalogEntry<DS[number]['key']>
468
+ )
469
+ }
470
+ }
471
+
472
+ /** The statement for a period's rows, and for its totals when the query groups. */
473
+ function periodStatements(
474
+ dataset: AnyDataset,
475
+ query: NormalizedQuery,
476
+ context: PlanContext
477
+ ) {
478
+ const grouped = query.groupBy.length > 0
479
+
480
+ return {
481
+ rows: planQuery(dataset, query, context, true),
482
+ totals: grouped ? planQuery(dataset, query, context, false) : null
483
+ }
484
+ }
485
+
486
+ function resolveSource(
487
+ dataset: AnyDataset,
488
+ sources: Record<string, AnalyticsSource>,
489
+ sourceNames: string[]
490
+ ): AnalyticsSource {
491
+ const name =
492
+ dataset.source ?? (sourceNames.length === 1 ? sourceNames[0] : undefined)
493
+ if (!name) {
494
+ throw new Error(
495
+ `Dataset ${dataset.key} names no source, and there are several: ${sourceNames.join(', ')}`
496
+ )
497
+ }
498
+
499
+ const source = sources[name]
500
+ if (!source)
501
+ throw new Error(`Dataset ${dataset.key} uses unknown source ${name}`)
502
+
503
+ return source
504
+ }
505
+
506
+ /**
507
+ * Compiles every measure, grouping and filter once, so a definition the dialect cannot render
508
+ * fails when the app starts instead of on the first query.
509
+ */
510
+ function compileEverything(dataset: AnyDataset, dialect: Dialect): void {
511
+ const now = new Date(0)
512
+ const context: PlanContext = {
513
+ tenant: 'tenant',
514
+ timezone: 'UTC',
515
+ period: resolvePeriod({ last: { days: 1 } }, 'UTC', now),
516
+ now,
517
+ resolvedValues: new Map()
518
+ }
519
+ const measures = Object.keys(dataset.measures)
520
+ const base = normalizeQuery(dataset, { dataset: dataset.key, measures })
521
+
522
+ try {
523
+ dialect.compile(planQuery(dataset, base, context, true))
524
+
525
+ for (const [key, dimension] of Object.entries<AnyDimension>(
526
+ dataset.dimensions
527
+ )) {
528
+ if (dimension.groupable) {
529
+ dialect.compile(
530
+ planQuery(dataset, { ...base, groupBy: [key] }, context, true)
531
+ )
532
+ }
533
+ if (dimension.filterable) {
534
+ const filter =
535
+ dimension.category === 'numeric'
536
+ ? { dimension: key, op: 'between' as const, from: 0, to: 1 }
537
+ : dimension.category === 'time'
538
+ ? {
539
+ dimension: key,
540
+ op: 'between' as const,
541
+ from: '2000-01-01',
542
+ to: '2000-01-02'
543
+ }
544
+ : { dimension: key, op: 'in' as const, values: ['value'] }
545
+ dialect.compile(
546
+ planQuery(dataset, { ...base, filters: [filter] }, context, true)
547
+ )
548
+ }
549
+ }
550
+ } catch (error) {
551
+ const message = error instanceof Error ? error.message : String(error)
552
+ throw new Error(
553
+ `Dataset ${dataset.key} does not compile for ${dialect.name}: ${message}`
554
+ )
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Turns the keys or labels given for open dimensions (tags, teams) into keys. An unknown value
560
+ * fails with the valid values listed, so a model can fix its query in one retry.
561
+ */
562
+ async function resolveOpenValues(
563
+ dataset: AnyDataset,
564
+ query: NormalizedQuery,
565
+ tenant: string | number,
566
+ now: Date,
567
+ run: (statement: SelectStatement) => Promise<readonly Row[]>,
568
+ dialect: Dialect
569
+ ): Promise<Map<string, (string | number)[]>> {
570
+ const resolved = new Map<string, (string | number)[]>()
571
+
572
+ for (const filter of query.filters) {
573
+ if (filter.op !== 'in' && filter.op !== 'notIn') continue
574
+ const dimension: AnyDimension | undefined =
575
+ dataset.dimensions[filter.dimension]
576
+ if (dimension?.category !== 'open') continue
577
+
578
+ const wanted = filter.values.map(String)
579
+ const found = await run(
580
+ planLookup(dataset, filter.dimension, tenant, wanted, now)
581
+ )
582
+ const keys = new Set<string | number>()
583
+ const unknown: string[] = []
584
+
585
+ for (const value of wanted) {
586
+ const matches = found.filter(
587
+ (row) =>
588
+ String(row.key) === value ||
589
+ String(row.label ?? '').toLowerCase() === value.toLowerCase()
590
+ )
591
+ if (matches.length === 0) unknown.push(value)
592
+ for (const match of matches) keys.add(decodeKey(match.key, dialect))
593
+ }
594
+
595
+ if (unknown.length > 0) {
596
+ const valid = (
597
+ await run(planLookup(dataset, filter.dimension, tenant, null, now))
598
+ ).map((row) => ({
599
+ key: String(decodeKey(row.key, dialect)),
600
+ label: String(row.label ?? row.key)
601
+ }))
602
+ const list = valid
603
+ .map((entry) => `${entry.label} (${entry.key})`)
604
+ .join(', ')
605
+ const quoted = unknown.map((value) => `"${value}"`).join(', ')
606
+ throw new AnalyticsError(
607
+ 'unknown_value',
608
+ `Unknown ${dimension.label.toLowerCase()}: ${quoted}. Valid values: ${list || 'none'}.`,
609
+ { dimension: filter.dimension, unknown, valid }
610
+ )
611
+ }
612
+
613
+ resolved.set(filter.dimension, [...keys])
614
+ }
615
+
616
+ return resolved
617
+ }
618
+
619
+ function decodeKey(value: unknown, dialect: Dialect): string | number {
620
+ if (typeof value === 'string') return value
621
+
622
+ return dialect.decodeNumber(value) ?? String(value)
623
+ }
624
+
625
+ function catalogEntry(dataset: AnyDataset): DatasetCatalogEntry {
626
+ return {
627
+ key: dataset.key,
628
+ label: dataset.label,
629
+ description: dataset.description,
630
+ time: Object.entries<{ label: string }>(dataset.time).map(
631
+ ([key, field]) => ({
632
+ key,
633
+ label: field.label
634
+ })
635
+ ),
636
+ measures: Object.entries<{
637
+ label: string
638
+ description: string | undefined
639
+ }>(dataset.measures).map(([key, entry]) => ({
640
+ key,
641
+ label: entry.label,
642
+ description: entry.description
643
+ })),
644
+ dimensions: Object.entries<AnyDimension>(dataset.dimensions).map(
645
+ ([key, entry]) => ({
646
+ key,
647
+ label: entry.label,
648
+ description: entry.description,
649
+ kind: entry.kind,
650
+ category: entry.category,
651
+ groupable: entry.groupable,
652
+ filterable: entry.filterable,
653
+ values:
654
+ entry.kind === 'enum' || entry.kind === 'computed'
655
+ ? Object.entries(entry.values).map(([value, label]) => ({
656
+ key: value,
657
+ label
658
+ }))
659
+ : undefined
660
+ })
661
+ )
662
+ }
663
+ }