measurr 0.5.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.
@@ -4,7 +4,7 @@ import {
4
4
  PeriodError,
5
5
  resolvePeriod,
6
6
  previousPeriod
7
- } from "./index-g255jb1s.js";
7
+ } from "./index-115syy01.js";
8
8
 
9
9
  // src/query.ts
10
10
  import { z } from "zod";
@@ -108,7 +108,8 @@ function datasetQuerySchema(dataset) {
108
108
  throw new Error(`Dataset ${dataset.key} has no measures`);
109
109
  const groupKeys = nonEmpty(groupable.map(([key]) => key));
110
110
  const timeKeys = nonEmpty(Object.keys(dataset.time));
111
- const sortKeys = nonEmpty([...measureKeys, ...groupKeys ?? []]) ?? measureKeys;
111
+ const sqlMeasureKeys = measures.filter(([, entry]) => entry.kind !== "resolved").map(([key]) => key);
112
+ const sortKeys = nonEmpty([...sqlMeasureKeys, ...groupKeys ?? []]) ?? measureKeys;
112
113
  const notes = groupable.filter(([, dim]) => dim.kind === "manyToMany" && dim.note).map(([key, dim]) => `${key}: ${dim.kind === "manyToMany" ? dim.note : ""}`);
113
114
  const [firstFilter, ...otherFilters] = filterable.map(([key, dim]) => filterSchema(key, dim));
114
115
  return z.object({
@@ -142,6 +143,14 @@ function datasetQuerySchema(dataset) {
142
143
  message: `Sort by one of the asked measures or grouped dimensions: ${returned.join(", ")}`
143
144
  });
144
145
  }
146
+ const sortMeasure = by === undefined ? undefined : dataset.measures[by];
147
+ if (sortMeasure?.kind === "resolved") {
148
+ context.addIssue({
149
+ code: "custom",
150
+ path: ["sort", "by"],
151
+ message: `${by} is computed after the query runs and cannot be sorted by`
152
+ });
153
+ }
145
154
  const period = query.period ?? dataset.defaultPeriod;
146
155
  if (query.compareToPrevious && "all" in period) {
147
156
  context.addIssue({
@@ -256,8 +265,23 @@ function measureNode(dataset, key) {
256
265
  left: measureNode(dataset, entry.numerator),
257
266
  right: measureNode(dataset, entry.denominator)
258
267
  };
268
+ case "resolved":
269
+ throw new Error(`Dataset ${dataset.key}: measure ${key} is resolved by the app, not the database`);
259
270
  }
260
271
  }
272
+ function sqlMeasures(dataset, keys) {
273
+ const result = new Set;
274
+ for (const key of keys) {
275
+ const entry = measureOf(dataset, key);
276
+ if (entry.kind === "resolved") {
277
+ for (const used of entry.uses)
278
+ result.add(used);
279
+ } else {
280
+ result.add(key);
281
+ }
282
+ }
283
+ return [...result];
284
+ }
261
285
  function dimensionNodes(dataset, key, timezone) {
262
286
  const entry = dimensionOf(dataset, key);
263
287
  switch (entry.kind) {
@@ -410,12 +434,19 @@ function planQuery(dataset, query, context, grouped) {
410
434
  select.push({ alias: columnAlias.groupLabel(key), node: nodes.label });
411
435
  }
412
436
  }
413
- for (const key of query.measures) {
437
+ const measureKeys = sqlMeasures(dataset, query.measures);
438
+ for (const key of measureKeys) {
414
439
  select.push({
415
440
  alias: columnAlias.measure(key),
416
441
  node: measureNode(dataset, key)
417
442
  });
418
443
  }
444
+ if (select.length === 0) {
445
+ select.push({
446
+ alias: "row_count",
447
+ node: { kind: "aggregate", fn: "count", arg: null, where: null }
448
+ });
449
+ }
419
450
  const where = [
420
451
  ...baseWhere(dataset, context.tenant, context.period && {
421
452
  time: query.time,
@@ -442,7 +473,10 @@ function orderBy(dataset, query, select) {
442
473
  const kind = dimensionOf(dataset, key).kind;
443
474
  return kind === "timeBucket" || kind === "timePart";
444
475
  });
445
- const sort = query.sort ?? (timeKey ? { by: timeKey, direction: "asc" } : { by: query.measures[0] ?? "", direction: "desc" });
476
+ const sort = query.sort ?? (timeKey ? { by: timeKey, direction: "asc" } : {
477
+ by: sqlMeasures(dataset, query.measures)[0] ?? "",
478
+ direction: "desc"
479
+ });
446
480
  const sortIndex = sort.by in dataset.measures ? indexOf(columnAlias.measure(sort.by)) : indexOf(columnAlias.groupKey(sort.by));
447
481
  if (sortIndex >= 0)
448
482
  order.push({ index: sortIndex, direction: sort.direction });
@@ -546,8 +580,11 @@ function labelOf(dimension, key, rawLabel) {
546
580
  function shapeMeasures(dataset, measures, raw, dialect) {
547
581
  const values = {};
548
582
  for (const key of measures) {
583
+ const entry = measureOf(dataset, key);
584
+ if (entry.kind === "resolved")
585
+ continue;
549
586
  const value = dialect.decodeNumber(raw[columnAlias.measure(key)]);
550
- values[key] = measureOf(dataset, key).result === "count" ? value ?? 0 : value;
587
+ values[key] = entry.result === "count" ? value ?? 0 : value;
551
588
  }
552
589
  return values;
553
590
  }
@@ -566,7 +603,7 @@ function shapeRows(dataset, query, rawRows, dialect) {
566
603
  }
567
604
  return { ...row, ...shapeMeasures(dataset, query.measures, raw, dialect) };
568
605
  });
569
- return { rows, truncated };
606
+ return { rows, kept, truncated };
570
607
  }
571
608
  function resultNotes(dataset, query) {
572
609
  const notes = [];
@@ -613,6 +650,25 @@ function createAnalytics2(options) {
613
650
  }
614
651
  restrictedOf.set(dataset.key, restricted);
615
652
  }
653
+ const resolvers = options.resolvers ?? {};
654
+ for (const [datasetKey, entries] of Object.entries(resolvers)) {
655
+ const dataset = byKey.get(datasetKey);
656
+ if (!dataset)
657
+ throw new Error(`Resolvers name unknown dataset ${datasetKey}`);
658
+ for (const key of Object.keys(entries ?? {})) {
659
+ const entry = dataset.measures[key];
660
+ if (entry?.kind !== "resolved") {
661
+ throw new Error(`Resolvers name ${datasetKey}.${key}, which is not a resolved measure`);
662
+ }
663
+ }
664
+ }
665
+ for (const dataset of datasets) {
666
+ for (const [key, entry] of Object.entries(dataset.measures)) {
667
+ if (entry.kind === "resolved" && !resolvers[dataset.key]?.[key]) {
668
+ throw new Error(`Dataset ${dataset.key} resolves ${key} in the app, but createAnalytics has no resolver for it`);
669
+ }
670
+ }
671
+ }
616
672
  const now = options.now ?? (() => new Date);
617
673
  const querySchemas = new Map;
618
674
  const resultSchemas = new Map;
@@ -685,6 +741,13 @@ function createAnalytics2(options) {
685
741
  }
686
742
  return querySchemaFor(allowed);
687
743
  };
744
+ const timezoneOf = async (ctx) => {
745
+ const timezone = await options.timezone?.(ctx) || "UTC";
746
+ if (!isValidTimezone(timezone)) {
747
+ throw new AnalyticsError2("invalid_timezone", `Unknown timezone ${timezone}`);
748
+ }
749
+ return timezone;
750
+ };
688
751
  const prepare = async (input, ctx) => {
689
752
  const parsed = querySchemaFor(datasets).safeParse(input);
690
753
  if (!parsed.success) {
@@ -711,10 +774,7 @@ function createAnalytics2(options) {
711
774
  if (!dataset.tenantColumn && tenant !== allTenants2) {
712
775
  throw new AnalyticsError2("forbidden", `${dataset.key} has no tenant column; only a caller allowed to see every tenant may query it`);
713
776
  }
714
- const timezone = await options.timezone?.(ctx) || "UTC";
715
- if (!isValidTimezone(timezone)) {
716
- throw new AnalyticsError2("invalid_timezone", `Unknown timezone ${timezone}`);
717
- }
777
+ const timezone = await timezoneOf(ctx);
718
778
  const query = normalizeQuery(dataset, parsedQuery);
719
779
  const at = now();
720
780
  let period;
@@ -729,12 +789,13 @@ function createAnalytics2(options) {
729
789
  const previous = query.compareToPrevious && period ? previousPeriod(period) : null;
730
790
  return { dataset, query, tenant, timezone, period, previous, now: at };
731
791
  };
732
- const runQuery = async (prepared) => {
792
+ const runQuery = async (prepared, ctx) => {
733
793
  const { dataset, query, tenant, timezone, period, previous, now } = prepared;
734
794
  const source = sourceFor(dataset);
735
795
  const { dialect } = source;
736
796
  const run = (statement) => source.execute(dialect.compile(statement));
737
797
  const resolvedValues = await resolveOpenValues(dataset, query, tenant, now, run, dialect);
798
+ const range = ({ from, to }) => ({ from, to, timezone });
738
799
  const runPeriod = async (target) => {
739
800
  const statements = periodStatements(dataset, query, {
740
801
  tenant,
@@ -747,12 +808,46 @@ function createAnalytics2(options) {
747
808
  run(statements.rows),
748
809
  statements.totals ? run(statements.totals) : null
749
810
  ]);
750
- const { rows, truncated } = shapeRows(dataset, query, rawRows, dialect);
811
+ const { rows, kept, truncated } = shapeRows(dataset, query, rawRows, dialect);
751
812
  const totalsRow = (rawTotals ?? rawRows)[0] ?? {};
752
813
  const totals = shapeMeasures(dataset, query.measures, totalsRow, dialect);
814
+ for (const key of query.measures) {
815
+ const entry = dataset.measures[key];
816
+ if (entry?.kind !== "resolved")
817
+ continue;
818
+ const used = (raw) => shapeMeasures(dataset, entry.uses, raw, dialect);
819
+ const resolver = resolvers[dataset.key]?.[key];
820
+ if (!resolver)
821
+ throw new Error(`No resolver for ${dataset.key}.${key}`);
822
+ const output = await resolver({
823
+ rows: kept.map((raw, index) => ({
824
+ dimensions: Object.fromEntries(query.groupBy.map((group) => [group, rows[index]?.[group]])),
825
+ values: used(raw)
826
+ })),
827
+ totals: used(totalsRow),
828
+ groupBy: query.groupBy,
829
+ filters: query.filters.map((filter) => {
830
+ const resolved = resolvedValues.get(filter.dimension);
831
+ return resolved && "values" in filter ? { ...filter, values: [...resolved] } : filter;
832
+ }),
833
+ period: target ? range(target) : { all: true, timezone },
834
+ tenant,
835
+ ctx
836
+ });
837
+ if (output.rows.length !== rows.length) {
838
+ throw new Error(`The resolver for ${dataset.key}.${key} returned ${output.rows.length} values for ${rows.length} rows`);
839
+ }
840
+ const value = (raw) => {
841
+ const finite = typeof raw === "number" && Number.isFinite(raw) ? raw : null;
842
+ return entry.result === "count" ? finite ?? 0 : finite;
843
+ };
844
+ rows.forEach((row, index) => {
845
+ row[key] = value(output.rows[index]);
846
+ });
847
+ totals[key] = value(output.totals);
848
+ }
753
849
  return { rows, totals, truncated };
754
850
  };
755
- const range = ({ from, to }) => ({ from, to, timezone });
756
851
  const [current, before] = await Promise.all([
757
852
  runPeriod(period),
758
853
  previous && runPeriod(previous).then(({ rows, totals }) => ({
@@ -810,7 +905,7 @@ function createAnalytics2(options) {
810
905
  if (key)
811
906
  cache = result ? "hit" : "miss";
812
907
  if (!result) {
813
- result = await runQuery(prepared);
908
+ result = await runQuery(prepared, ctx);
814
909
  if (key)
815
910
  store(key, result, entry);
816
911
  }
@@ -858,6 +953,7 @@ function createAnalytics2(options) {
858
953
  explain,
859
954
  querySchema: (schemaOptions) => schemaOptions && ("ctx" in schemaOptions) ? querySchemaForCtx(schemaOptions.ctx) : querySchemaFor(schemaOptions?.datasets?.map(datasetOf) ?? datasets),
860
955
  resultSchema: (key) => resultSchemaFor(key),
956
+ timezone: timezoneOf,
861
957
  listDatasets: async (ctx) => (await allowedDatasets(ctx)).map((dataset) => catalogEntry(dataset))
862
958
  };
863
959
  }
@@ -978,7 +1074,18 @@ function restrictedFields(dataset) {
978
1074
  if (!entry)
979
1075
  return [];
980
1076
  const own = entry.access === undefined ? [] : [entry.access];
981
- return entry.kind === "ratio" ? [...own, ...accessOf(entry.numerator), ...accessOf(entry.denominator)] : own;
1077
+ switch (entry.kind) {
1078
+ case "ratio":
1079
+ return [
1080
+ ...own,
1081
+ ...accessOf(entry.numerator),
1082
+ ...accessOf(entry.denominator)
1083
+ ];
1084
+ case "resolved":
1085
+ return [...own, ...entry.uses.flatMap(accessOf)];
1086
+ default:
1087
+ return own;
1088
+ }
982
1089
  };
983
1090
  return [
984
1091
  ...Object.keys(measures).map((key) => ({
@@ -0,0 +1,46 @@
1
+ import {
2
+ AnalyticsError2
3
+ } from "./index-g0ah4ehn.js";
4
+
5
+ // src/handler.ts
6
+ async function queryBatch2(analytics, queries, ctx) {
7
+ const results = {};
8
+ const errors = {};
9
+ for await (const chunk of streamBatch2(analytics, queries, ctx)) {
10
+ if ("error" in chunk)
11
+ errors[chunk.key] = chunk.error;
12
+ else
13
+ results[chunk.key] = chunk.result;
14
+ }
15
+ return { results, errors };
16
+ }
17
+ async function* streamBatch2(analytics, queries, ctx) {
18
+ const pending = new Map(Object.entries(queries).map(([key, query]) => {
19
+ const settled = analytics.query(query, ctx).then((result) => ({ key, result }), (error) => ({ key, error: batchError(error) }));
20
+ return [key, settled];
21
+ }));
22
+ while (pending.size > 0) {
23
+ const chunk = await Promise.race(pending.values());
24
+ pending.delete(chunk.key);
25
+ yield chunk;
26
+ }
27
+ }
28
+ function batchError(error) {
29
+ return error instanceof AnalyticsError2 ? { code: error.code, message: error.message } : { code: "internal", message: "The query failed" };
30
+ }
31
+ function toHttpError2(error) {
32
+ if (error.code === "forbidden") {
33
+ return { status: 403, code: "FORBIDDEN", message: error.message };
34
+ }
35
+ if (error.code === "missing_tenant") {
36
+ return { status: 401, code: "UNAUTHORIZED", message: error.message };
37
+ }
38
+ return {
39
+ status: 400,
40
+ code: "BAD_REQUEST",
41
+ message: error.message,
42
+ data: { code: error.code, details: error.details }
43
+ };
44
+ }
45
+
46
+ export { queryBatch2, streamBatch2, toHttpError2 };
@@ -4,7 +4,7 @@ import {
4
4
  } from "./index-e6k30sfp.js";
5
5
  import {
6
6
  DEFAULT_PERIOD
7
- } from "./index-g255jb1s.js";
7
+ } from "./index-115syy01.js";
8
8
 
9
9
  // src/dataset.ts
10
10
  function aggregate(result, fn, arg, options) {
@@ -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-zgb1e89d.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-g2mjqx4f.js";
17
+ } from "./index-rgqsrd72.js";
18
18
  import {
19
19
  Expr2,
20
20
  Table2,
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Named dashboard metrics: one query over a dataset and the shape it is drawn in. The
3
+ * registry runs a batch of them for a caller and yields each as it finishes; the period, the
4
+ * previous period and the tenant scope come from the request and `ctx`, never from a metric.
5
+ */
6
+ import { z } from 'zod';
7
+ import type { Analytics } from './analytics.js';
8
+ import type { AnyDataset } from './dataset.js';
9
+ import type { DatasetQuery, QueryResult, ResultOf } from './query.js';
10
+ import type { MetricOutput, OutputForKind, OutputKind, TimeGranularity } from './shapes.js';
11
+ /** The buckets a time series is grouped by. */
12
+ export type MetricBucket = 'day' | 'week' | 'month';
13
+ /** What a metric's `query` and `shape` are run with. */
14
+ export type MetricRun<Filters> = {
15
+ /** The metric's own filters, parsed with its `filterSchema`. */
16
+ filters: Filters;
17
+ /** The time-bucket dimension to group a series by: the granularity, as near as it gets. */
18
+ bucket: MetricBucket;
19
+ granularity: TimeGranularity;
20
+ };
21
+ /** A result as `shape` receives it: one period, without `previous`. */
22
+ export type MetricQueryResult<DS, Q> = ResultOf<DS, Q> extends QueryResult<infer Row, infer Totals, infer _Period> ? Omit<QueryResult<Row, Totals>, 'previous'> : never;
23
+ export type MetricDefinition<DS extends AnyDataset, Key extends string, Kind extends OutputKind, Filters, Q extends DatasetQuery<DS>, Output extends OutputForKind<Kind>, Role extends string = string> = {
24
+ key: Key;
25
+ kind: Kind;
26
+ description: string;
27
+ /** For `canRun`; the registry does not read it. */
28
+ allowedRoles?: readonly Role[] | undefined;
29
+ /** False for a snapshot of now: it counts all time and has no previous period. */
30
+ supportsTimeRange: boolean;
31
+ filterSchema: z.ZodType<Filters>;
32
+ /** The query, without `period` or `compareToPrevious`: the registry sets those. */
33
+ query: (run: MetricRun<Filters>) => Q;
34
+ /** Called once for the period and once for the previous period. */
35
+ shape: (result: MetricQueryResult<DS, Q>, run: MetricRun<Filters>) => Output;
36
+ };
37
+ /**
38
+ * `defineMetric` for metrics over the datasets `DS` (a union), with `allowedRoles` typed as
39
+ * `Role` and the app's own fields `Extra` (catalog metadata, say), which the registry carries
40
+ * along but does not read. Types only, so metric modules need not import the analytics
41
+ * instance:
42
+ *
43
+ * ```ts
44
+ * export const defineMetric = metricDefiner<(typeof datasets)[number], Role>()
45
+ * ```
46
+ */
47
+ export declare function metricDefiner<DS extends AnyDataset, Role extends string = string, Extra extends object = Record<never, never>>(): <const Key extends string, const Kind extends OutputKind, Filters, const Q extends DatasetQuery<DS>, Output extends OutputForKind<Kind>>(definition: MetricDefinition<DS, Key, Kind, Filters, Q, Output, Role> & Extra) => MetricDefinition<DS, Key, Kind, Filters, Q, Output, Role> & Extra;
48
+ /** Any metric, as the registry holds them. */
49
+ export type AnyMetric = {
50
+ key: string;
51
+ kind: OutputKind;
52
+ description: string;
53
+ allowedRoles?: readonly string[] | undefined;
54
+ supportsTimeRange: boolean;
55
+ filterSchema: z.ZodType<any>;
56
+ query: (run: MetricRun<any>) => DatasetQuery<AnyDataset>;
57
+ shape: (result: any, run: MetricRun<any>) => MetricOutput;
58
+ };
59
+ export type MetricKeyOf<R extends {
60
+ metrics: readonly AnyMetric[];
61
+ }> = R['metrics'][number]['key'];
62
+ export type MetricOf<R extends {
63
+ metrics: readonly AnyMetric[];
64
+ }, K extends MetricKeyOf<R>> = Extract<R['metrics'][number], {
65
+ key: K;
66
+ }>;
67
+ export type MetricOutputOf<R extends {
68
+ metrics: readonly AnyMetric[];
69
+ }, K extends MetricKeyOf<R>> = ReturnType<MetricOf<R, K>['shape']>;
70
+ export type MetricFiltersOf<R extends {
71
+ metrics: readonly AnyMetric[];
72
+ }, K extends MetricKeyOf<R>> = z.infer<MetricOf<R, K>['filterSchema']>;
73
+ export type MetricKeysOfKind<R extends {
74
+ metrics: readonly AnyMetric[];
75
+ }, Kind extends OutputKind> = Extract<R['metrics'][number], {
76
+ kind: Kind;
77
+ }>['key'];
78
+ /** One metric's numbers: the period, and the previous period on the same buckets. */
79
+ export type MetricResult<Output = MetricOutput> = {
80
+ current: Output;
81
+ previous?: Output;
82
+ supportsTimeRange: boolean;
83
+ };
84
+ /** One metric's result or error, as each finishes. */
85
+ export type MetricChunk<M extends AnyMetric = AnyMetric> = M extends AnyMetric ? {
86
+ key: M['key'];
87
+ /** The request's name for it; the key unless one request asks for a metric twice. */
88
+ requestKey: string;
89
+ /** Whether this is the last chunk of the request. */
90
+ done: boolean;
91
+ } & ({
92
+ result: MetricResult<ReturnType<M['shape']>>;
93
+ error?: undefined;
94
+ } | {
95
+ error: string;
96
+ result?: undefined;
97
+ }) : never;
98
+ export type MetricsRequest<Key extends string = string> = {
99
+ metrics: readonly {
100
+ key: Key;
101
+ requestKey?: string | undefined;
102
+ filters?: unknown;
103
+ }[];
104
+ /** Instants; the registry counts the local days they fall on in `analytics.timezone(ctx)`. */
105
+ from?: Date | undefined;
106
+ to?: Date | undefined;
107
+ /** Inferred from the range when omitted: day up to 31 days, week up to 90, else month. */
108
+ granularity?: TimeGranularity | undefined;
109
+ compareToPrevious?: boolean | undefined;
110
+ };
111
+ export type MetricRegistry<M extends readonly AnyMetric[], Ctx> = {
112
+ readonly metrics: M;
113
+ /** The keys, for a request schema or a catalog. */
114
+ readonly keys: [M[number]['key'], ...M[number]['key'][]];
115
+ /** The request schema; `.extend` it with the app's own fields. */
116
+ readonly requestSchema: z.ZodObject<{
117
+ metrics: z.ZodArray<z.ZodObject<{
118
+ key: z.ZodEnum<{
119
+ [K in M[number]['key']]: K;
120
+ }>;
121
+ requestKey: z.ZodOptional<z.ZodString>;
122
+ filters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
123
+ }>>;
124
+ from: z.ZodOptional<z.ZodDate>;
125
+ to: z.ZodOptional<z.ZodDate>;
126
+ granularity: z.ZodOptional<z.ZodEnum<{
127
+ [G in TimeGranularity]: G;
128
+ }>>;
129
+ compareToPrevious: z.ZodDefault<z.ZodBoolean>;
130
+ }>;
131
+ get<K extends M[number]['key']>(key: K): Extract<M[number], {
132
+ key: K;
133
+ }>;
134
+ /** The metrics `canRun` allows for `ctx`. */
135
+ available(ctx: Ctx): Promise<M[number][]>;
136
+ /**
137
+ * Runs the requested metrics in parallel and yields each as it finishes. A metric `ctx` may
138
+ * not run, or whose filters do not parse, fails alone. A metric that supports a time range
139
+ * counts `from` to `to` (all time without them) and compares with the previous period when
140
+ * asked; a snapshot counts all time.
141
+ */
142
+ run(request: MetricsRequest<M[number]['key']>, ctx: Ctx): AsyncGenerator<MetricChunk<M[number]>, void, undefined>;
143
+ };
144
+ export type MetricRegistryOptions<DS extends readonly AnyDataset[], M extends readonly AnyMetric[], Ctx> = {
145
+ analytics: Analytics<DS, Ctx>;
146
+ metrics: M;
147
+ /** Whether `ctx` may run `metric`. Every metric runs when omitted. */
148
+ canRun?: ((metric: M[number], ctx: Ctx) => boolean | Promise<boolean>) | undefined;
149
+ /** Hears a `shape` that throws; the metric fails with "The metric could not be drawn". */
150
+ onShapeError?: ((error: unknown, metric: M[number], ctx: Ctx) => unknown) | undefined;
151
+ };
152
+ export declare function createMetricRegistry<DS extends readonly AnyDataset[], const M extends readonly AnyMetric[], Ctx>(options: MetricRegistryOptions<DS, M, Ctx>): MetricRegistry<M, Ctx>;