measurr 0.6.0 → 0.7.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.
@@ -62,8 +62,19 @@ var measure2 = {
62
62
  numerator,
63
63
  denominator
64
64
  };
65
- }
65
+ },
66
+ resolved
66
67
  };
68
+ function resolved(options) {
69
+ return {
70
+ kind: "resolved",
71
+ result: options.result,
72
+ label: options.label,
73
+ description: options.description,
74
+ access: options.access,
75
+ uses: options.uses ?? []
76
+ };
77
+ }
67
78
  function base(options) {
68
79
  return {
69
80
  label: options.label,
@@ -222,6 +233,15 @@ function validateDataset(dataset) {
222
233
  }
223
234
  if (entry.kind === "ratio")
224
235
  checkRatio(measures, key, [], fail);
236
+ if (entry.kind === "resolved") {
237
+ for (const used of entry.uses) {
238
+ const usedEntry = measures[used];
239
+ if (!usedEntry)
240
+ fail(`measure ${key} uses unknown measure ${used}`);
241
+ if (usedEntry?.kind === "resolved")
242
+ fail(`measure ${key} uses ${used}, which is resolved too`);
243
+ }
244
+ }
225
245
  }
226
246
  const dimensions = dataset.dimensions;
227
247
  for (const [key, entry] of Object.entries(dimensions)) {
@@ -266,6 +286,8 @@ function checkRatio(measures, key, path, fail) {
266
286
  const entry = measures[key];
267
287
  if (!entry)
268
288
  fail(`ratio measure ${path.at(-1)} names unknown measure ${key}`);
289
+ if (entry?.kind === "resolved")
290
+ fail(`ratio measure ${path.at(-1)} names ${key}, which is resolved`);
269
291
  if (entry?.kind !== "ratio")
270
292
  return;
271
293
  checkRatio(measures, entry.numerator, [...path, key], fail);
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- export { type AccessKey, type Analytics, type AnalyticsCache, AnalyticsError, type AnalyticsErrorCode, type AnalyticsErrorDetails, type AnalyticsErrorOf, type AnalyticsOptions, type AnalyticsQuery, type AnalyticsQueryEvent, type AnalyticsResult, type AnalyticsSource, createAnalytics, type DatasetCatalogEntry, type FieldKey, isAnalyticsError } from './analytics.js';
1
+ export { type AccessKey, type Analytics, type AnalyticsCache, AnalyticsError, type AnalyticsErrorCode, type AnalyticsErrorDetails, type AnalyticsErrorOf, type AnalyticsOptions, type AnalyticsQuery, type AnalyticsQueryEvent, type AnalyticsResolvers, type AnalyticsResult, type AnalyticsSource, createAnalytics, type DatasetCatalogEntry, type FieldKey, isAnalyticsError, type MeasureResolver, type ResolvedKey, type ResolverInput, type ResolverOutput } from './analytics.js';
2
2
  export { CompileError, type CompiledStatement, type Dialect, type JoinClause, type SelectItem, type SelectStatement, type SqlRenderer, decodeNumber, renderStatement } from './compile.js';
3
- export { type AnyDataset, type AnyDimension, type AnyMeasure, type Dataset, type DimensionValue, defineDataset, dimension, type MeasureValue, measure, type Ratio } from './dataset.js';
3
+ export { type AnyDataset, type AnyDimension, type AnyMeasure, type Dataset, type DimensionValue, defineDataset, dimension, type MeasureValue, measure, type Ratio, type ResolvedMeasure } from './dataset.js';
4
4
  export { and, caseWhen, coalesce, type ColumnKey, eq, Expr, type ExprNode, exists, gt, gte, inList, isNotNull, isNull, literal, lt, lte, minutesAgo, ne, not, or, type SqlType, Table, type TableRef, table, toText } from './expr.js';
5
5
  export { analyticsToolGuidance } from './guidance.js';
6
6
  export type { PeriodInput, PeriodPreset } from './period.js';
7
- export { allTenants, type Tenant, type TenantId } from './plan.js';
7
+ export { allTenants, type NormalizedFilter, type Tenant, type TenantId } from './plan.js';
8
8
  export { AnalyticsResultSchema, type DatasetFilter, type DatasetQuery, type QueryResult, type ResultOf, type ResultPeriod } from './query.js';
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  AnalyticsError2,
5
5
  isAnalyticsError2,
6
6
  createAnalytics2
7
- } from "./index-z5xv3ffk.js";
7
+ } from "./index-g0ah4ehn.js";
8
8
  import {
9
9
  CompileError2,
10
10
  decodeNumber2,
@@ -14,7 +14,7 @@ import {
14
14
  measure2,
15
15
  dimension2,
16
16
  defineDataset2
17
- } from "./index-pgccd363.js";
17
+ } from "./index-rgqsrd72.js";
18
18
  import {
19
19
  Expr2,
20
20
  Table2,
package/dist/metrics.js CHANGED
@@ -1,7 +1,7 @@
1
- import"./index-z5xv3ffk.js";
1
+ import"./index-g0ah4ehn.js";
2
2
  import {
3
3
  streamBatch2
4
- } from "./index-xf6qpmgy.js";
4
+ } from "./index-psw9s799.js";
5
5
  import {
6
6
  localDate
7
7
  } from "./index-115syy01.js";
package/dist/plan.d.ts CHANGED
@@ -64,6 +64,11 @@ export type PlanContext = {
64
64
  /** Keys for filters on open dimensions, after label resolution, by dimension. */
65
65
  resolvedValues: ReadonlyMap<string, readonly (string | number)[]>;
66
66
  };
67
+ /**
68
+ * The measures the database computes for `keys`: a resolved measure brings the measures it
69
+ * uses instead of itself.
70
+ */
71
+ export declare function sqlMeasures(dataset: AnyDataset, keys: readonly string[]): string[];
67
72
  /** Output column aliases. Keys come from definitions, never from input. */
68
73
  export declare const columnAlias: {
69
74
  measure: (key: string) => string;
@@ -85,10 +90,11 @@ export type ShapedValue = {
85
90
  label: string;
86
91
  };
87
92
  export type ShapedRow = Record<string, ShapedValue | number | null>;
88
- /** Decodes measures in one raw row; counts are never null. */
93
+ /** Decodes measures in one raw row; counts are never null. Resolved measures are left out. */
89
94
  export declare function shapeMeasures(dataset: AnyDataset, measures: readonly string[], raw: Record<string, unknown>, dialect: Dialect): Record<string, number | null>;
90
95
  export declare function shapeRows(dataset: AnyDataset, query: NormalizedQuery, rawRows: readonly Record<string, unknown>[], dialect: Dialect): {
91
96
  rows: ShapedRow[];
97
+ kept: readonly Record<string, unknown>[];
92
98
  truncated: boolean;
93
99
  };
94
100
  /** Notes a reader needs to read the numbers right. */
package/dist/query.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { AnyDataset, Dataset, DimensionValue, MeasureValue } from './dataset.js';
2
+ import type { AnyDataset, Dataset, DimensionValue, MeasureValue, SqlMeasureKey } from './dataset.js';
3
3
  import { type PeriodInput } from './period.js';
4
4
  export declare const MAX_MEASURES = 4;
5
5
  export declare const MAX_GROUP_BY = 2;
@@ -61,7 +61,7 @@ export type DatasetQuery<DS> = DS extends Dataset<infer K, infer M, infer Dm, in
61
61
  time?: T | undefined;
62
62
  compareToPrevious?: boolean | undefined;
63
63
  sort?: {
64
- by: (keyof M & string) | GroupableKey<Dm>;
64
+ by: SqlMeasureKey<M> | GroupableKey<Dm>;
65
65
  direction?: 'asc' | 'desc' | undefined;
66
66
  } | undefined;
67
67
  limit?: number | undefined;
package/dist/testing.js CHANGED
@@ -3,11 +3,11 @@ import {
3
3
  allTenants2,
4
4
  isAnalyticsError2,
5
5
  createAnalytics2
6
- } from "./index-z5xv3ffk.js";
6
+ } from "./index-g0ah4ehn.js";
7
7
  import {
8
8
  measure2,
9
9
  dimension2
10
- } from "./index-pgccd363.js";
10
+ } from "./index-rgqsrd72.js";
11
11
 
12
12
  // src/testing.ts
13
13
  var COUNT = "__contractCount";
@@ -17,10 +17,11 @@ async function checkDatasetContract(options) {
17
17
  const timezone = options.timezone ?? "Pacific/Auckland";
18
18
  const period = options.period ?? { last: { days: 365 } };
19
19
  const emptyTenant = options.emptyTenant ?? (typeof options.tenant === "number" ? -1 : "__contract_empty_tenant__");
20
+ const sqlMeasures = Object.entries(dataset.measures).filter(([, entry]) => entry.kind !== "resolved");
20
21
  const probe = {
21
22
  ...dataset,
22
23
  measures: {
23
- ...dataset.measures,
24
+ ...Object.fromEntries(sqlMeasures),
24
25
  [COUNT]: measure2.count({ label: "Rows" })
25
26
  },
26
27
  dimensions: {
@@ -67,7 +68,7 @@ async function checkDatasetContract(options) {
67
68
  message: `${scope} has no rows in the period; the checks below prove little`
68
69
  });
69
70
  }
70
- for (const key of Object.keys(dataset.measures)) {
71
+ for (const [key] of sqlMeasures) {
71
72
  await check(`measure ${key}`, async () => {
72
73
  const value = (await run({ measures: [key] })).totals[key];
73
74
  if (value !== null && typeof value !== "number")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "measurr",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -22,6 +22,7 @@
22
22
  "analytics",
23
23
  "sql",
24
24
  "postgres",
25
+ "clickhouse",
25
26
  "zod",
26
27
  "llm-tools"
27
28
  ],
@@ -45,6 +46,12 @@
45
46
  "import": "./dist/dialects/postgres.js",
46
47
  "default": "./dist/dialects/postgres.js"
47
48
  },
49
+ "./clickhouse": {
50
+ "types": "./dist/dialects/clickhouse.d.ts",
51
+ "bun": "./src/dialects/clickhouse.ts",
52
+ "import": "./dist/dialects/clickhouse.js",
53
+ "default": "./dist/dialects/clickhouse.js"
54
+ },
48
55
  "./shapes": {
49
56
  "types": "./dist/shapes.d.ts",
50
57
  "bun": "./src/shapes.ts",
@@ -72,7 +79,7 @@
72
79
  },
73
80
  "scripts": {
74
81
  "build": "rm -rf dist && bun run build:js && bun run build:types",
75
- "build:js": "bun build ./src/index.ts ./src/dialects/postgres.ts ./src/shapes.ts ./src/handler.ts ./src/metrics.ts ./src/testing.ts --root ./src --outdir ./dist --target node --packages external --splitting",
82
+ "build:js": "bun build ./src/index.ts ./src/dialects/postgres.ts ./src/dialects/clickhouse.ts ./src/shapes.ts ./src/handler.ts ./src/metrics.ts ./src/testing.ts --root ./src --outdir ./dist --target node --packages external --splitting",
76
83
  "build:types": "tsgo -p tsconfig.build.json",
77
84
  "prepublishOnly": "bun run build",
78
85
  "test": "bun test",
package/src/analytics.ts CHANGED
@@ -4,9 +4,13 @@ import type {
4
4
  AnyDataset,
5
5
  AnyDimension,
6
6
  AnyMeasure,
7
- Dataset
7
+ Dataset,
8
+ DimensionValue,
9
+ MeasureValue,
10
+ ResolvedMeasure
8
11
  } from './dataset.js'
9
12
  import {
13
+ type NormalizedFilter,
10
14
  type NormalizedQuery,
11
15
  type ParsedQuery,
12
16
  type PlanContext,
@@ -31,6 +35,7 @@ import {
31
35
  type DatasetQuery,
32
36
  type QueryResult,
33
37
  type ResultOf,
38
+ type ResultPeriod,
34
39
  buildQuerySchema,
35
40
  datasetResultSchema
36
41
  } from './query.js'
@@ -128,6 +133,65 @@ export type AccessKey<D> =
128
133
  ? NonNullable<(M & Dm)[keyof (M & Dm)]['access']>
129
134
  : never
130
135
 
136
+ /** The resolved measures of a dataset. */
137
+ export type ResolvedKey<D> =
138
+ D extends Dataset<string, infer M>
139
+ ? {
140
+ [K in keyof M & string]: M[K] extends { kind: 'resolved' } ? K : never
141
+ }[keyof M & string]
142
+ : never
143
+
144
+ /** The values of the measures resolved measure `K` uses. */
145
+ type UsedValues<D, K> =
146
+ D extends Dataset<string, infer M>
147
+ ? M[K & keyof M] extends ResolvedMeasure<MeasureResult, infer U>
148
+ ? { [Used in U & keyof M]: MeasureValue<M[Used]> }
149
+ : never
150
+ : never
151
+
152
+ type MeasureResult = ResolvedMeasure['result']
153
+
154
+ /** What a resolver gets for one period of one query. */
155
+ export type ResolverInput<D, K, Ctx> =
156
+ D extends Dataset<string, infer _M, infer Dm>
157
+ ? {
158
+ /**
159
+ * The result rows in order: their grouped dimensions and the measures `K` uses. One
160
+ * row when the query has no `groupBy`.
161
+ */
162
+ rows: readonly {
163
+ dimensions: { [G in keyof Dm]?: DimensionValue<Dm[G]> }
164
+ values: UsedValues<D, K>
165
+ }[]
166
+ /** The used measures over every row of the period, ungrouped. */
167
+ totals: UsedValues<D, K>
168
+ groupBy: readonly (keyof Dm & string)[]
169
+ /** The query's filters, open-dimension labels already resolved to keys. */
170
+ filters: readonly NormalizedFilter[]
171
+ /** The period being resolved: the query's, or its previous one. */
172
+ period: ResultPeriod
173
+ tenant: Tenant
174
+ ctx: Ctx
175
+ }
176
+ : never
177
+
178
+ /** One value per row, in the same order, and one for the totals. */
179
+ export type ResolverOutput = {
180
+ rows: readonly (number | null)[]
181
+ totals: number | null
182
+ }
183
+
184
+ export type MeasureResolver<D, K, Ctx> = (
185
+ input: ResolverInput<D, K, Ctx>
186
+ ) => ResolverOutput | Promise<ResolverOutput>
187
+
188
+ /** The code for every resolved measure, by dataset and measure. */
189
+ export type AnalyticsResolvers<DS extends readonly AnyDataset[], Ctx> = {
190
+ [D in DS[number] as [ResolvedKey<D>] extends [never] ? never : D['key']]: {
191
+ [K in ResolvedKey<D>]: MeasureResolver<D, K, Ctx>
192
+ }
193
+ }
194
+
131
195
  export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
132
196
  datasets: DS
133
197
  /** Databases by name; a dataset's `source` picks one, or the only one is used. */
@@ -179,6 +243,13 @@ export type AnalyticsOptions<DS extends readonly AnyDataset[], Ctx> = {
179
243
  * swallowed: a failing cache is a miss, never a failed query.
180
244
  */
181
245
  cache?: AnalyticsCache<Ctx, DS[number]['key']> | undefined
246
+ /**
247
+ * The code for `measure.resolved` measures, by dataset and measure key: called once per
248
+ * period with the result rows and the values of the measures each one uses. Required for
249
+ * every resolved measure (`createAnalytics` throws otherwise). What it throws fails the
250
+ * query; a value that is not a finite number counts as null.
251
+ */
252
+ resolvers?: NoInfer<AnalyticsResolvers<DS, Ctx>> | undefined
182
253
  }
183
254
 
184
255
  /**
@@ -272,6 +343,9 @@ export type AnalyticsResult<A, Q> =
272
343
 
273
344
  type ShapedResult = QueryResult<Row, Row>
274
345
 
346
+ // biome-ignore lint/suspicious/noExplicitAny: resolvers are typed per dataset where they are declared.
347
+ type AnyResolver = (input: any) => ResolverOutput | Promise<ResolverOutput>
348
+
275
349
  /** A measure or dimension marked with `access`, with every access key it needs. */
276
350
  type RestrictedField = {
277
351
  kind: 'measure' | 'dimension'
@@ -318,6 +392,33 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
318
392
  restrictedOf.set(dataset.key, restricted)
319
393
  }
320
394
 
395
+ const resolvers = (options.resolvers ?? {}) as Record<
396
+ string,
397
+ Record<string, AnyResolver> | undefined
398
+ >
399
+ for (const [datasetKey, entries] of Object.entries(resolvers)) {
400
+ const dataset = byKey.get(datasetKey)
401
+ if (!dataset)
402
+ throw new Error(`Resolvers name unknown dataset ${datasetKey}`)
403
+ for (const key of Object.keys(entries ?? {})) {
404
+ const entry: AnyMeasure | undefined = dataset.measures[key]
405
+ if (entry?.kind !== 'resolved') {
406
+ throw new Error(
407
+ `Resolvers name ${datasetKey}.${key}, which is not a resolved measure`
408
+ )
409
+ }
410
+ }
411
+ }
412
+ for (const dataset of datasets) {
413
+ for (const [key, entry] of Object.entries<AnyMeasure>(dataset.measures)) {
414
+ if (entry.kind === 'resolved' && !resolvers[dataset.key]?.[key]) {
415
+ throw new Error(
416
+ `Dataset ${dataset.key} resolves ${key} in the app, but createAnalytics has no resolver for it`
417
+ )
418
+ }
419
+ }
420
+ }
421
+
321
422
  const now = options.now ?? (() => new Date())
322
423
  const querySchemas = new Map<string, z.ZodType>()
323
424
  const resultSchemas = new Map<string, z.ZodType>()
@@ -507,7 +608,10 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
507
608
  return { dataset, query, tenant, timezone, period, previous, now: at }
508
609
  }
509
610
 
510
- const runQuery = async (prepared: Prepared): Promise<ShapedResult> => {
611
+ const runQuery = async (
612
+ prepared: Prepared,
613
+ ctx: Ctx
614
+ ): Promise<ShapedResult> => {
511
615
  const { dataset, query, tenant, timezone, period, previous, now } = prepared
512
616
  const source = sourceFor(dataset)
513
617
  const { dialect } = source
@@ -523,6 +627,8 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
523
627
  dialect
524
628
  )
525
629
 
630
+ const range = ({ from, to }: ResolvedPeriod) => ({ from, to, timezone })
631
+
526
632
  const runPeriod = async (target: ResolvedPeriod | null) => {
527
633
  const statements = periodStatements(dataset, query, {
528
634
  tenant,
@@ -536,14 +642,63 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
536
642
  statements.totals ? run(statements.totals) : null
537
643
  ])
538
644
 
539
- const { rows, truncated } = shapeRows(dataset, query, rawRows, dialect)
645
+ const { rows, kept, truncated } = shapeRows(
646
+ dataset,
647
+ query,
648
+ rawRows,
649
+ dialect
650
+ )
540
651
  const totalsRow = (rawTotals ?? rawRows)[0] ?? {}
541
652
  const totals = shapeMeasures(dataset, query.measures, totalsRow, dialect)
542
653
 
654
+ for (const key of query.measures) {
655
+ const entry: AnyMeasure | undefined = dataset.measures[key]
656
+ if (entry?.kind !== 'resolved') continue
657
+
658
+ const used = (raw: Row) =>
659
+ shapeMeasures(dataset, entry.uses, raw, dialect)
660
+ const resolver = resolvers[dataset.key]?.[key]
661
+ if (!resolver) throw new Error(`No resolver for ${dataset.key}.${key}`)
662
+
663
+ const output = await resolver({
664
+ rows: kept.map((raw, index) => ({
665
+ dimensions: Object.fromEntries(
666
+ query.groupBy.map((group) => [group, rows[index]?.[group]])
667
+ ),
668
+ values: used(raw)
669
+ })),
670
+ totals: used(totalsRow),
671
+ groupBy: query.groupBy,
672
+ filters: query.filters.map((filter) => {
673
+ const resolved = resolvedValues.get(filter.dimension)
674
+ return resolved && 'values' in filter
675
+ ? { ...filter, values: [...resolved] }
676
+ : filter
677
+ }),
678
+ period: target ? range(target) : { all: true, timezone },
679
+ tenant,
680
+ ctx
681
+ })
682
+ if (output.rows.length !== rows.length) {
683
+ throw new Error(
684
+ `The resolver for ${dataset.key}.${key} returned ${output.rows.length} values for ${rows.length} rows`
685
+ )
686
+ }
687
+
688
+ const value = (raw: number | null | undefined) => {
689
+ const finite =
690
+ typeof raw === 'number' && Number.isFinite(raw) ? raw : null
691
+ return entry.result === 'count' ? (finite ?? 0) : finite
692
+ }
693
+ rows.forEach((row, index) => {
694
+ row[key] = value(output.rows[index])
695
+ })
696
+ totals[key] = value(output.totals)
697
+ }
698
+
543
699
  return { rows, totals, truncated }
544
700
  }
545
701
 
546
- const range = ({ from, to }: ResolvedPeriod) => ({ from, to, timezone })
547
702
  const [current, before] = await Promise.all([
548
703
  runPeriod(period),
549
704
  previous &&
@@ -619,7 +774,7 @@ export function createAnalytics<const DS extends readonly AnyDataset[], Ctx>(
619
774
  let result = key ? await cachedResult(key, entry) : null
620
775
  if (key) cache = result ? 'hit' : 'miss'
621
776
  if (!result) {
622
- result = await runQuery(prepared)
777
+ result = await runQuery(prepared, ctx)
623
778
  if (key) store(key, result, entry)
624
779
  }
625
780
 
@@ -918,14 +1073,23 @@ function decodeKey(value: unknown, dialect: Dialect): string | number {
918
1073
  */
919
1074
  function restrictedFields(dataset: AnyDataset): RestrictedField[] {
920
1075
  const measures: Record<string, AnyMeasure> = dataset.measures
921
- // defineDataset refuses ratio cycles.
1076
+ // defineDataset refuses ratio cycles, and resolved measures use only SQL measures.
922
1077
  const accessOf = (key: string): string[] => {
923
1078
  const entry = measures[key]
924
1079
  if (!entry) return []
925
1080
  const own = entry.access === undefined ? [] : [entry.access]
926
- return entry.kind === 'ratio'
927
- ? [...own, ...accessOf(entry.numerator), ...accessOf(entry.denominator)]
928
- : own
1081
+ switch (entry.kind) {
1082
+ case 'ratio':
1083
+ return [
1084
+ ...own,
1085
+ ...accessOf(entry.numerator),
1086
+ ...accessOf(entry.denominator)
1087
+ ]
1088
+ case 'resolved':
1089
+ return [...own, ...entry.uses.flatMap(accessOf)]
1090
+ default:
1091
+ return own
1092
+ }
929
1093
  }
930
1094
 
931
1095
  return [
package/src/dataset.ts CHANGED
@@ -76,7 +76,29 @@ export type RatioMeasure<
76
76
  readonly denominator: B
77
77
  }
78
78
 
79
- export type AnyMeasure = AggregateMeasure | ShareMeasure | RatioMeasure
79
+ /**
80
+ * A value the app computes after the query runs, from the measures it `uses` in each row and
81
+ * from anything else it can reach (another database, a forecast). The `resolvers` option of
82
+ * `createAnalytics` supplies the code.
83
+ */
84
+ export type ResolvedMeasure<
85
+ R extends MeasureResult = MeasureResult,
86
+ U extends string = string
87
+ > = {
88
+ readonly kind: 'resolved'
89
+ readonly result: R
90
+ readonly label: string
91
+ readonly description: string | undefined
92
+ /** A resolved measure also needs the access of the measures it uses. */
93
+ readonly access: string | undefined
94
+ readonly uses: readonly U[]
95
+ }
96
+
97
+ export type AnyMeasure =
98
+ | AggregateMeasure
99
+ | ShareMeasure
100
+ | RatioMeasure
101
+ | ResolvedMeasure
80
102
 
81
103
  /** The TypeScript type of a measure's value in a result row. */
82
104
  export type MeasureValue<M> = M extends { result: 'count' }
@@ -198,6 +220,41 @@ export const measure = {
198
220
  numerator,
199
221
  denominator
200
222
  }
223
+ },
224
+
225
+ resolved
226
+ }
227
+
228
+ /**
229
+ * A value the app computes after the query runs, for numbers one statement cannot give:
230
+ * another database, a forecast, a per-day average. `uses` names the measures of this dataset
231
+ * the resolver reads from each row; they are fetched even when the query does not ask for
232
+ * them. The code goes in `createAnalytics({ resolvers })`. Queries cannot sort by it, since
233
+ * the database limits the rows before the value exists.
234
+ */
235
+ function resolved<R extends MeasureResult, Access extends string = never>(
236
+ options: Described<Access> & { result: R; uses?: undefined }
237
+ ): ResolvedMeasure<R, never> & Marked<NoInfer<Access>>
238
+ function resolved<
239
+ R extends MeasureResult,
240
+ const U extends string,
241
+ Access extends string = never
242
+ >(
243
+ options: Described<Access> & { result: R; uses: readonly U[] }
244
+ ): ResolvedMeasure<R, U> & Marked<NoInfer<Access>>
245
+ function resolved(
246
+ options: Described & {
247
+ result: MeasureResult
248
+ uses?: readonly string[] | undefined
249
+ }
250
+ ): ResolvedMeasure {
251
+ return {
252
+ kind: 'resolved',
253
+ result: options.result,
254
+ label: options.label,
255
+ description: options.description,
256
+ access: options.access,
257
+ uses: options.uses ?? []
201
258
  }
202
259
  }
203
260
 
@@ -618,14 +675,24 @@ export type Dataset<
618
675
  // biome-ignore lint/suspicious/noExplicitAny: the widest dataset, used only as a constraint.
619
676
  export type AnyDataset = Dataset<string, any, any, string>
620
677
 
621
- /** Ratio measures may only name other measures of the same dataset. */
678
+ /** Measures the database computes: everything but resolved measures. */
679
+ export type SqlMeasureKey<M> = {
680
+ [Key in keyof M & string]: M[Key] extends { kind: 'resolved' } ? never : Key
681
+ }[keyof M & string]
682
+
683
+ /**
684
+ * Ratio measures may only name other SQL measures of the same dataset, and resolved measures
685
+ * may only use SQL measures.
686
+ */
622
687
  type CheckMeasures<M> = {
623
688
  [Key in keyof M]: M[Key] extends { kind: 'ratio' }
624
689
  ? {
625
- numerator: Exclude<keyof M & string, Key>
626
- denominator: Exclude<keyof M & string, Key>
690
+ numerator: Exclude<SqlMeasureKey<M>, Key>
691
+ denominator: Exclude<SqlMeasureKey<M>, Key>
627
692
  }
628
- : unknown
693
+ : M[Key] extends { kind: 'resolved' }
694
+ ? { uses: readonly SqlMeasureKey<M>[] }
695
+ : unknown
629
696
  }
630
697
 
631
698
  /** Time dimensions may only name declared time fields. */
@@ -764,6 +831,14 @@ function validateDataset<DS extends AnyDataset>(dataset: DS): DS {
764
831
  if (entry.denominator) check(entry.denominator, `measure ${key}`)
765
832
  }
766
833
  if (entry.kind === 'ratio') checkRatio(measures, key, [], fail)
834
+ if (entry.kind === 'resolved') {
835
+ for (const used of entry.uses) {
836
+ const usedEntry = measures[used]
837
+ if (!usedEntry) fail(`measure ${key} uses unknown measure ${used}`)
838
+ if (usedEntry?.kind === 'resolved')
839
+ fail(`measure ${key} uses ${used}, which is resolved too`)
840
+ }
841
+ }
767
842
  }
768
843
 
769
844
  const dimensions: Record<string, AnyDimension> = dataset.dimensions
@@ -820,6 +895,8 @@ function checkRatio(
820
895
 
821
896
  const entry = measures[key]
822
897
  if (!entry) fail(`ratio measure ${path.at(-1)} names unknown measure ${key}`)
898
+ if (entry?.kind === 'resolved')
899
+ fail(`ratio measure ${path.at(-1)} names ${key}, which is resolved`)
823
900
  if (entry?.kind !== 'ratio') return
824
901
 
825
902
  checkRatio(measures, entry.numerator, [...path, key], fail)