measurr 0.7.0 → 0.8.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/dist/handler.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * as each finishes), and engine errors as HTTP errors. Framework-neutral; an oRPC or tRPC
4
4
  * procedure wraps these in a few lines.
5
5
  */
6
- import { type Analytics, AnalyticsError, type AnalyticsErrorCode, type AnalyticsErrorDetails } from './analytics.js';
6
+ import { type Analytics, AnalyticsError, type AnalyticsErrorCode, type AnalyticsErrorDetails, type QueryOptions } from './analytics.js';
7
7
  import type { AnyDataset } from './dataset.js';
8
8
  import type { DatasetQuery, ResultOf } from './query.js';
9
9
  /** Why one query of a batch failed. Errors other than `AnalyticsError` are not described. */
@@ -33,9 +33,14 @@ type Queries<DS extends readonly AnyDataset[]> = Record<string, DatasetQuery<DS[
33
33
  * Runs named queries in parallel. One query failing does not fail the others: its error is
34
34
  * under its name. Each query is authorized, cached and reported like a single `query`.
35
35
  */
36
- export declare function queryBatch<DS extends readonly AnyDataset[], Ctx, const Q extends Queries<DS>>(analytics: Analytics<DS, Ctx>, queries: Q, ctx: Ctx): Promise<BatchResult<DS, Q>>;
37
- /** Runs named queries in parallel and yields each as it finishes, for a streaming endpoint. */
38
- export declare function streamBatch<DS extends readonly AnyDataset[], Ctx, const Q extends Queries<DS>>(analytics: Analytics<DS, Ctx>, queries: Q, ctx: Ctx): AsyncGenerator<BatchChunk<DS, Q>, void, undefined>;
36
+ export declare function queryBatch<DS extends readonly AnyDataset[], Ctx, const Q extends Queries<DS>>(analytics: Analytics<DS, Ctx>, queries: Q, ctx: Ctx, options?: BatchOptions): Promise<BatchResult<DS, Q>>;
37
+ /** Aborting `signal` stops the batch's statements and rejects with its reason. */
38
+ export type BatchOptions = Pick<QueryOptions, 'signal'>;
39
+ /**
40
+ * Runs named queries in parallel and yields each as it finishes, for a streaming endpoint. A
41
+ * consumer that stops reading (a closed stream) stops the statements still running.
42
+ */
43
+ export declare function streamBatch<DS extends readonly AnyDataset[], Ctx, const Q extends Queries<DS>>(analytics: Analytics<DS, Ctx>, queries: Q, ctx: Ctx, options?: BatchOptions): AsyncGenerator<BatchChunk<DS, Q>, void, undefined>;
39
44
  export type HttpError = {
40
45
  status: 400 | 401 | 403;
41
46
  /** The status name oRPC and tRPC use for it. */
package/dist/handler.js CHANGED
@@ -1,11 +1,57 @@
1
1
  import {
2
- queryBatch2,
3
- streamBatch2,
4
- toHttpError2
5
- } from "./index-psw9s799.js";
6
- import"./index-g0ah4ehn.js";
2
+ AnalyticsError2
3
+ } from "./index-8nhqje4s.js";
4
+
5
+ // src/handler.ts
6
+ async function queryBatch(analytics, queries, ctx, options) {
7
+ const results = {};
8
+ const errors = {};
9
+ for await (const chunk of streamBatch(analytics, queries, ctx, options)) {
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* streamBatch(analytics, queries, ctx, options = {}) {
18
+ options.signal?.throwIfAborted();
19
+ const controller = new AbortController;
20
+ const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
21
+ try {
22
+ const pending = new Map(Object.entries(queries).map(([key, query]) => {
23
+ const settled = analytics.query(query, ctx, { signal }).then((result) => ({ key, result }), (error) => ({ key, error: batchError(error) }));
24
+ return [key, settled];
25
+ }));
26
+ while (pending.size > 0) {
27
+ const chunk = await Promise.race(pending.values());
28
+ signal.throwIfAborted();
29
+ pending.delete(chunk.key);
30
+ yield chunk;
31
+ }
32
+ } finally {
33
+ controller.abort();
34
+ }
35
+ }
36
+ function batchError(error) {
37
+ return error instanceof AnalyticsError2 ? { code: error.code, message: error.message } : { code: "internal", message: "The query failed" };
38
+ }
39
+ function toHttpError(error) {
40
+ if (error.code === "forbidden") {
41
+ return { status: 403, code: "FORBIDDEN", message: error.message };
42
+ }
43
+ if (error.code === "missing_tenant") {
44
+ return { status: 401, code: "UNAUTHORIZED", message: error.message };
45
+ }
46
+ return {
47
+ status: 400,
48
+ code: "BAD_REQUEST",
49
+ message: error.message,
50
+ data: { code: error.code, details: error.details }
51
+ };
52
+ }
7
53
  export {
8
- queryBatch2 as queryBatch,
9
- streamBatch2 as streamBatch,
10
- toHttpError2 as toHttpError
54
+ queryBatch,
55
+ streamBatch,
56
+ toHttpError
11
57
  };
@@ -98,7 +98,8 @@ function filterSchema(key, dim) {
98
98
  ]).describe(`${description} (bucket start dates, inclusive)`);
99
99
  }
100
100
  }
101
- function datasetQuerySchema(dataset) {
101
+ function datasetQuerySchema(dataset, options = {}) {
102
+ const maxLimit = options.maxLimit ?? MAX_LIMIT;
102
103
  const measures = Object.entries(dataset.measures);
103
104
  const dimensions = Object.entries(dataset.dimensions);
104
105
  const groupable = dimensions.filter(([, dim]) => dim.groupable);
@@ -131,7 +132,8 @@ function datasetQuerySchema(dataset) {
131
132
  by: z.enum(sortKeys),
132
133
  direction: z.enum(["asc", "desc"]).optional()
133
134
  }).optional().describe("One of the asked measures or grouped dimensions. Defaults to time order when grouped by time, otherwise the first measure, descending."),
134
- limit: z.number().int().min(1).max(MAX_LIMIT).optional().describe(`Maximum number of groups. Defaults to ${DEFAULT_LIMIT}.`)
135
+ limit: z.number().int().min(1).max(maxLimit).optional().describe(`Maximum number of groups. Defaults to ${DEFAULT_LIMIT}.`),
136
+ ...options.parse && { totals: z.boolean().optional() }
135
137
  }).superRefine((query, context) => {
136
138
  const { groupBy } = query;
137
139
  const by = query.sort?.by;
@@ -161,8 +163,8 @@ function datasetQuerySchema(dataset) {
161
163
  }
162
164
  });
163
165
  }
164
- function buildQuerySchema(datasets) {
165
- const [first, ...rest] = datasets.map(datasetQuerySchema);
166
+ function buildQuerySchema(datasets, options = {}) {
167
+ const [first, ...rest] = datasets.map((dataset) => datasetQuerySchema(dataset, options));
166
168
  if (!first)
167
169
  throw new Error("querySchema needs at least one dataset");
168
170
  return z.discriminatedUnion("dataset", [first, ...rest]);
@@ -181,14 +183,41 @@ var dimensionValue = z.object({
181
183
  key: z.union([z.string(), z.number()]).nullable(),
182
184
  label: z.string()
183
185
  });
186
+ var fieldsOutput = z.object({
187
+ groupBy: z.array(z.object({
188
+ key: z.string(),
189
+ label: z.string(),
190
+ kind: z.enum([
191
+ "enum",
192
+ "computed",
193
+ "relation",
194
+ "manyToMany",
195
+ "number",
196
+ "timePart",
197
+ "timeBucket"
198
+ ]),
199
+ unit: z.enum(["day", "week", "month"]).optional(),
200
+ values: z.array(z.object({ key: z.string(), label: z.string() })).optional()
201
+ })),
202
+ measures: z.array(z.object({
203
+ key: z.string(),
204
+ label: z.string(),
205
+ valueType: z.enum(["count", "number", "ratio"])
206
+ }))
207
+ });
184
208
  function resultSchemaOf(row, totals) {
185
209
  return z.object({
186
210
  period: periodOutput,
187
211
  rows: z.array(row),
188
- totals,
189
- previous: z.object({ period: rangeOutput, rows: z.array(row), totals }).optional(),
212
+ totals: totals.optional(),
213
+ previous: z.object({
214
+ period: rangeOutput,
215
+ rows: z.array(row),
216
+ totals: totals.optional()
217
+ }).optional(),
190
218
  notes: z.array(z.string()),
191
- truncated: z.boolean()
219
+ truncated: z.boolean(),
220
+ fields: fieldsOutput.optional()
192
221
  });
193
222
  }
194
223
  function datasetResultSchema(dataset) {
@@ -216,7 +245,8 @@ function normalizeQuery(dataset, parsed) {
216
245
  time: parsed.time ?? dataset.defaultTime,
217
246
  compareToPrevious: parsed.compareToPrevious ?? false,
218
247
  sort: parsed.sort ? { by: parsed.sort.by, direction: parsed.sort.direction ?? "desc" } : null,
219
- limit: parsed.limit ?? DEFAULT_LIMIT
248
+ limit: parsed.limit ?? DEFAULT_LIMIT,
249
+ totals: parsed.totals ?? true
220
250
  };
221
251
  }
222
252
  var allTenants2 = Symbol("measurr.allTenants");
@@ -320,25 +350,20 @@ function dimensionNodes(dataset, key, timezone) {
320
350
  }
321
351
  }
322
352
  }
353
+ function tenantWhere(column, tenant) {
354
+ const ids = typeof tenant === "object" ? [...new Set(tenant)] : [tenant];
355
+ const [only] = ids;
356
+ return [
357
+ ids.length === 1 && only !== undefined ? { kind: "compare", op: "=", left: column, right: param(only) } : { kind: "in", item: column, values: ids.map(param), negated: false }
358
+ ];
359
+ }
323
360
  function baseWhere(dataset, tenant, period) {
324
361
  const where = [];
325
362
  if (tenant !== allTenants2) {
326
363
  if (!dataset.tenantColumn) {
327
364
  throw new Error(`Dataset ${dataset.key} has no tenant column; only allTenants may query it`);
328
365
  }
329
- const ids = typeof tenant === "object" ? [...new Set(tenant)] : [tenant];
330
- const [only] = ids;
331
- where.push(ids.length === 1 && only !== undefined ? {
332
- kind: "compare",
333
- op: "=",
334
- left: dataset.tenantColumn,
335
- right: param(only)
336
- } : {
337
- kind: "in",
338
- item: dataset.tenantColumn,
339
- values: ids.map(param),
340
- negated: false
341
- });
366
+ where.push(...tenantWhere(dataset.tenantColumn, tenant));
342
367
  }
343
368
  if (dataset.scope)
344
369
  where.push(dataset.scope);
@@ -403,7 +428,7 @@ function filterNode(dataset, filter, context) {
403
428
  values: values.map(param),
404
429
  negated: filter.op === "notIn"
405
430
  };
406
- if (filter.op === "in")
431
+ if (filter.op === "in" || neverNull(key))
407
432
  return list;
408
433
  return {
409
434
  kind: "logical",
@@ -413,6 +438,13 @@ function filterNode(dataset, filter, context) {
413
438
  }
414
439
  }
415
440
  }
441
+ function neverNull(node) {
442
+ if (node.kind === "param")
443
+ return node.value !== null;
444
+ if (node.kind !== "case" || !node.otherwise)
445
+ return false;
446
+ return neverNull(node.otherwise) && node.branches.every((branch) => neverNull(branch.result));
447
+ }
416
448
  var columnAlias = {
417
449
  measure: (key) => `m_${key}`,
418
450
  groupKey: (key) => `g_${key}`,
@@ -488,9 +520,11 @@ function orderBy(dataset, query, select) {
488
520
  return order;
489
521
  }
490
522
  function planLookup(dataset, dimensionKey, tenant, values, now) {
523
+ const dimension = dimensionOf(dataset, dimensionKey);
524
+ const lookup = dimension.kind === "relation" || dimension.kind === "manyToMany" ? dimension.lookup : undefined;
491
525
  const nodes = dimensionNodes(dataset, dimensionKey, "UTC");
492
526
  const label = nodes.label ?? nodes.key;
493
- const where = baseWhere(dataset, tenant, null);
527
+ const where = lookup ? lookupWhere(dataset, lookup.tenantColumn, tenant) : baseWhere(dataset, tenant, null);
494
528
  where.push({ kind: "isNull", item: nodes.key, negated: true });
495
529
  if (values) {
496
530
  where.push({
@@ -513,8 +547,8 @@ function planLookup(dataset, dimensionKey, tenant, values, now) {
513
547
  });
514
548
  }
515
549
  return {
516
- from: dataset.from,
517
- virtualJoins: nodes.virtualJoin ? [nodes.virtualJoin] : [],
550
+ from: lookup ? lookup.table : dataset.from,
551
+ virtualJoins: !lookup && nodes.virtualJoin ? [nodes.virtualJoin] : [],
518
552
  where,
519
553
  select: [
520
554
  { alias: "key", node: nodes.key },
@@ -526,6 +560,15 @@ function planLookup(dataset, dimensionKey, tenant, values, now) {
526
560
  now
527
561
  };
528
562
  }
563
+ function lookupWhere(dataset, tenantColumn, tenant) {
564
+ if (tenant === allTenants2 || !tenantColumn) {
565
+ if (!dataset.tenantColumn && tenant !== allTenants2) {
566
+ throw new Error(`Dataset ${dataset.key} has no tenant column; only allTenants may query it`);
567
+ }
568
+ return [];
569
+ }
570
+ return tenantWhere(tenantColumn, tenant);
571
+ }
529
572
  var WEEKDAYS = [
530
573
  "Monday",
531
574
  "Tuesday",
@@ -637,13 +680,17 @@ function createAnalytics2(options) {
637
680
  const byKey = new Map;
638
681
  const sourceOf = new Map;
639
682
  const restrictedOf = new Map;
683
+ const fingerprints = new Map;
640
684
  for (const dataset of datasets) {
641
685
  if (byKey.has(dataset.key))
642
686
  throw new Error(`Two datasets use the key ${dataset.key}`);
643
687
  byKey.set(dataset.key, dataset);
644
688
  const source = resolveSource(dataset, options.sources, sourceNames);
645
689
  sourceOf.set(dataset.key, source);
646
- compileEverything(dataset, source.dialect);
690
+ const compiled = compileEverything(dataset, source.dialect);
691
+ if (options.cache) {
692
+ fingerprints.set(dataset.key, sha256(compiled + stableJson(catalogEntry(dataset))));
693
+ }
647
694
  const restricted = restrictedFields(dataset);
648
695
  if (restricted.length > 0 && !options.authorizeField) {
649
696
  throw new Error(`Dataset ${dataset.key} marks fields with access, but createAnalytics has no authorizeField`);
@@ -672,6 +719,26 @@ function createAnalytics2(options) {
672
719
  const now = options.now ?? (() => new Date);
673
720
  const querySchemas = new Map;
674
721
  const resultSchemas = new Map;
722
+ const parseSchema = buildQuerySchema(datasets, {
723
+ parse: true,
724
+ maxLimit: options.maxLimit
725
+ });
726
+ const limiters = new Map;
727
+ if (options.concurrency !== undefined) {
728
+ for (const source of new Set(Object.values(options.sources)))
729
+ limiters.set(source, semaphore(options.concurrency));
730
+ }
731
+ const inFlight = new Map;
732
+ const execute = async (source, statement, signal) => {
733
+ signal?.throwIfAborted();
734
+ const release = await limiters.get(source)?.(signal);
735
+ try {
736
+ signal?.throwIfAborted();
737
+ return await (signal ? source.execute(statement, { signal }) : source.execute(statement));
738
+ } finally {
739
+ release?.();
740
+ }
741
+ };
675
742
  const datasetOf = (key) => {
676
743
  const dataset = byKey.get(key);
677
744
  if (!dataset)
@@ -741,6 +808,32 @@ function createAnalytics2(options) {
741
808
  }
742
809
  return querySchemaFor(allowed);
743
810
  };
811
+ const scopeOf = (ctx) => {
812
+ const tenant = options.tenant(ctx);
813
+ if (!isTenant(tenant)) {
814
+ throw new AnalyticsError2("missing_tenant", "Refusing to query without a tenant");
815
+ }
816
+ return tenant;
817
+ };
818
+ const resolveIn = (input, timezone, at) => {
819
+ if ("all" in input)
820
+ return null;
821
+ try {
822
+ return resolvePeriod(input, timezone, at);
823
+ } catch (error) {
824
+ if (error instanceof PeriodError) {
825
+ throw new AnalyticsError2("invalid_period", error.message);
826
+ }
827
+ throw error;
828
+ }
829
+ };
830
+ const periodOf = async (input, ctx) => {
831
+ const parsed = periodSchema.safeParse(input);
832
+ if (!parsed.success) {
833
+ throw new AnalyticsError2("invalid_period", `Invalid period: ${z2.prettifyError(parsed.error)}`);
834
+ }
835
+ return resolveIn(parsed.data, await timezoneOf(ctx), now());
836
+ };
744
837
  const timezoneOf = async (ctx) => {
745
838
  const timezone = await options.timezone?.(ctx) || "UTC";
746
839
  if (!isValidTimezone(timezone)) {
@@ -749,7 +842,7 @@ function createAnalytics2(options) {
749
842
  return timezone;
750
843
  };
751
844
  const prepare = async (input, ctx) => {
752
- const parsed = querySchemaFor(datasets).safeParse(input);
845
+ const parsed = parseSchema.safeParse(input);
753
846
  if (!parsed.success) {
754
847
  throw new AnalyticsError2("invalid_query", `Invalid analytics query: ${z2.prettifyError(parsed.error)}`, parsed.error.issues);
755
848
  }
@@ -767,33 +860,22 @@ function createAnalytics2(options) {
767
860
  const names = refused.map((field) => `${field.kind} ${field.key}`);
768
861
  throw new AnalyticsError2("forbidden", `Not allowed to use ${names.join(", ")} of ${dataset.key}`);
769
862
  }
770
- const tenant = options.tenant(ctx);
771
- if (!isTenant(tenant)) {
772
- throw new AnalyticsError2("missing_tenant", "Refusing to query without a tenant");
773
- }
863
+ const tenant = scopeOf(ctx);
774
864
  if (!dataset.tenantColumn && tenant !== allTenants2) {
775
865
  throw new AnalyticsError2("forbidden", `${dataset.key} has no tenant column; only a caller allowed to see every tenant may query it`);
776
866
  }
777
867
  const timezone = await timezoneOf(ctx);
778
868
  const query = normalizeQuery(dataset, parsedQuery);
779
869
  const at = now();
780
- let period;
781
- try {
782
- period = "all" in query.period ? null : resolvePeriod(query.period, timezone, at);
783
- } catch (error) {
784
- if (error instanceof PeriodError) {
785
- throw new AnalyticsError2("invalid_period", error.message);
786
- }
787
- throw error;
788
- }
870
+ const period = resolveIn(query.period, timezone, at);
789
871
  const previous = query.compareToPrevious && period ? previousPeriod(period) : null;
790
872
  return { dataset, query, tenant, timezone, period, previous, now: at };
791
873
  };
792
- const runQuery = async (prepared, ctx) => {
874
+ const runQuery = async (prepared, ctx, signal) => {
793
875
  const { dataset, query, tenant, timezone, period, previous, now } = prepared;
794
876
  const source = sourceFor(dataset);
795
877
  const { dialect } = source;
796
- const run = (statement) => source.execute(dialect.compile(statement));
878
+ const run = (statement) => execute(source, dialect.compile(statement), signal);
797
879
  const resolvedValues = await resolveOpenValues(dataset, query, tenant, now, run, dialect);
798
880
  const range = ({ from, to }) => ({ from, to, timezone });
799
881
  const runPeriod = async (target) => {
@@ -809,7 +891,8 @@ function createAnalytics2(options) {
809
891
  statements.totals ? run(statements.totals) : null
810
892
  ]);
811
893
  const { rows, kept, truncated } = shapeRows(dataset, query, rawRows, dialect);
812
- const totalsRow = (rawTotals ?? rawRows)[0] ?? {};
894
+ const grouped = query.groupBy.length > 0;
895
+ const totalsRow = (rawTotals ?? (grouped ? [] : rawRows))[0] ?? {};
813
896
  const totals = shapeMeasures(dataset, query.measures, totalsRow, dialect);
814
897
  for (const key of query.measures) {
815
898
  const entry = dataset.measures[key];
@@ -846,20 +929,20 @@ function createAnalytics2(options) {
846
929
  });
847
930
  totals[key] = value(output.totals);
848
931
  }
849
- return { rows, totals, truncated };
932
+ return { rows, ...query.totals && { totals }, truncated };
850
933
  };
851
934
  const [current, before] = await Promise.all([
852
935
  runPeriod(period),
853
936
  previous && runPeriod(previous).then(({ rows, totals }) => ({
854
937
  period: range(previous),
855
938
  rows,
856
- totals
939
+ ...totals && { totals }
857
940
  }))
858
941
  ]);
859
942
  return {
860
943
  period: period ? range(period) : { all: true, timezone },
861
944
  rows: current.rows,
862
- totals: current.totals,
945
+ ...current.totals && { totals: current.totals },
863
946
  ...before && { previous: before },
864
947
  notes: resultNotes(dataset, query),
865
948
  truncated: current.truncated
@@ -892,34 +975,48 @@ function createAnalytics2(options) {
892
975
  Promise.resolve(options.cache?.set(key, result, entry)).catch(() => {});
893
976
  } catch {}
894
977
  };
895
- const query = async (input, ctx) => {
978
+ const query = async (input, ctx, queryOptions = {}) => {
979
+ const { signal, metric } = queryOptions;
896
980
  const startedAt = performance.now();
897
981
  const dataset = datasetKeyOf(input);
898
982
  const durationMs = () => Math.round(performance.now() - startedAt);
899
983
  let cache;
900
984
  try {
985
+ signal?.throwIfAborted();
901
986
  const prepared = await prepare(input, ctx);
902
- const entry = { dataset: prepared.dataset.key, ctx };
903
- const key = options.cache && await cacheKey(prepared);
987
+ const entry = {
988
+ dataset: prepared.dataset.key,
989
+ ctx,
990
+ ...metric !== undefined && { metric }
991
+ };
992
+ const fingerprint = fingerprints.get(prepared.dataset.key);
993
+ const key = options.cache && fingerprint ? await cacheKey(prepared, await fingerprint) : null;
904
994
  let result = key ? await cachedResult(key, entry) : null;
905
995
  if (key)
906
996
  cache = result ? "hit" : "miss";
907
997
  if (!result) {
908
- result = await runQuery(prepared, ctx);
909
- if (key)
910
- store(key, result, entry);
998
+ result = key ? await share(inFlight, key, async (shared) => {
999
+ const fresh = await runQuery(prepared, ctx, shared);
1000
+ store(key, fresh, entry);
1001
+ return fresh;
1002
+ }, signal) : await runQuery(prepared, ctx, signal);
911
1003
  }
912
1004
  report({
913
1005
  dataset,
1006
+ ...metric !== undefined && { metric },
914
1007
  durationMs: durationMs(),
915
1008
  rows: result.rows.length,
916
1009
  outcome: "success",
917
1010
  ...cache && { cache }
918
1011
  }, ctx);
919
- return result;
1012
+ return {
1013
+ ...result,
1014
+ fields: resultFields(prepared.dataset, prepared.query)
1015
+ };
920
1016
  } catch (error) {
921
1017
  report({
922
1018
  dataset,
1019
+ ...metric !== undefined && { metric },
923
1020
  durationMs: durationMs(),
924
1021
  rows: 0,
925
1022
  outcome: "error",
@@ -954,23 +1051,94 @@ function createAnalytics2(options) {
954
1051
  querySchema: (schemaOptions) => schemaOptions && ("ctx" in schemaOptions) ? querySchemaForCtx(schemaOptions.ctx) : querySchemaFor(schemaOptions?.datasets?.map(datasetOf) ?? datasets),
955
1052
  resultSchema: (key) => resultSchemaFor(key),
956
1053
  timezone: timezoneOf,
1054
+ period: periodOf,
1055
+ scope: scopeOf,
957
1056
  listDatasets: async (ctx) => (await allowedDatasets(ctx)).map((dataset) => catalogEntry(dataset))
958
1057
  };
959
1058
  }
960
- async function cacheKey(prepared) {
1059
+ async function cacheKey(prepared, fingerprint) {
961
1060
  const { dataset, query, tenant, timezone, period, previous } = prepared;
962
1061
  const days = (target) => target && [target.from, target.to];
963
1062
  const scope = tenant === allTenants2 ? { all: true } : Array.isArray(tenant) ? [...new Set(tenant.map((id) => JSON.stringify(id)))].sort() : tenant;
964
1063
  const text = stableJson({
1064
+ definition: fingerprint,
965
1065
  query,
966
1066
  tenant: scope,
967
1067
  timezone,
968
1068
  period: days(period),
969
1069
  previous: days(previous)
970
1070
  });
1071
+ return `measurr:${dataset.key}:${await sha256(text)}`;
1072
+ }
1073
+ async function sha256(text) {
971
1074
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
972
- const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
973
- return `measurr:${dataset.key}:${hex}`;
1075
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
1076
+ }
1077
+ function semaphore(max) {
1078
+ if (!Number.isInteger(max) || max < 1)
1079
+ throw new Error(`concurrency must be a whole number of at least 1, not ${max}`);
1080
+ let active = 0;
1081
+ const queue = [];
1082
+ const release = () => {
1083
+ const next = queue.shift();
1084
+ if (next)
1085
+ next();
1086
+ else
1087
+ active--;
1088
+ };
1089
+ return async (signal) => {
1090
+ if (active < max) {
1091
+ active++;
1092
+ return release;
1093
+ }
1094
+ await new Promise((resolve, reject) => {
1095
+ const onAbort = () => {
1096
+ queue.splice(queue.indexOf(wake), 1);
1097
+ reject(signal?.reason);
1098
+ };
1099
+ const wake = () => {
1100
+ signal?.removeEventListener("abort", onAbort);
1101
+ resolve();
1102
+ };
1103
+ queue.push(wake);
1104
+ signal?.addEventListener("abort", onAbort, { once: true });
1105
+ });
1106
+ return release;
1107
+ };
1108
+ }
1109
+ function share(inFlight, key, start, signal) {
1110
+ let entry = inFlight.get(key);
1111
+ if (!entry) {
1112
+ const controller = new AbortController;
1113
+ const created = {
1114
+ controller,
1115
+ waiting: 0,
1116
+ promise: start(controller.signal).finally(() => {
1117
+ if (inFlight.get(key) === created)
1118
+ inFlight.delete(key);
1119
+ })
1120
+ };
1121
+ inFlight.set(key, created);
1122
+ entry = created;
1123
+ }
1124
+ const shared = entry;
1125
+ shared.waiting++;
1126
+ if (!signal)
1127
+ return shared.promise;
1128
+ return new Promise((resolve, reject) => {
1129
+ const onAbort = () => {
1130
+ if (--shared.waiting === 0) {
1131
+ if (inFlight.get(key) === shared)
1132
+ inFlight.delete(key);
1133
+ shared.controller.abort(signal.reason);
1134
+ }
1135
+ reject(signal.reason);
1136
+ };
1137
+ signal.addEventListener("abort", onAbort, { once: true });
1138
+ shared.promise.then(resolve, reject).finally(() => {
1139
+ signal.removeEventListener("abort", onAbort);
1140
+ });
1141
+ });
974
1142
  }
975
1143
  function stableJson(value) {
976
1144
  return JSON.stringify(value, (_key, inner) => inner && typeof inner === "object" && !Array.isArray(inner) ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => a < b ? -1 : 1)) : inner);
@@ -983,9 +1151,10 @@ function isTenantId(value) {
983
1151
  }
984
1152
  function periodStatements(dataset, query, context) {
985
1153
  const grouped = query.groupBy.length > 0;
1154
+ const resolved = query.measures.some((key) => dataset.measures[key]?.kind === "resolved");
986
1155
  return {
987
1156
  rows: planQuery(dataset, query, context, true),
988
- totals: grouped ? planQuery(dataset, query, context, false) : null
1157
+ totals: grouped && (query.totals || resolved) ? planQuery(dataset, query, context, false) : null
989
1158
  };
990
1159
  }
991
1160
  function resolveSource(dataset, sources, sourceNames) {
@@ -1009,11 +1178,15 @@ function compileEverything(dataset, dialect) {
1009
1178
  };
1010
1179
  const measures = Object.keys(dataset.measures);
1011
1180
  const base = normalizeQuery(dataset, { dataset: dataset.key, measures });
1181
+ const texts = [];
1182
+ const compile = (statement) => {
1183
+ texts.push(dialect.compile(statement).text);
1184
+ };
1012
1185
  try {
1013
- dialect.compile(planQuery(dataset, base, context, true));
1186
+ compile(planQuery(dataset, base, context, true));
1014
1187
  for (const [key, dimension] of Object.entries(dataset.dimensions)) {
1015
1188
  if (dimension.groupable) {
1016
- dialect.compile(planQuery(dataset, { ...base, groupBy: [key] }, context, true));
1189
+ compile(planQuery(dataset, { ...base, groupBy: [key] }, context, true));
1017
1190
  }
1018
1191
  if (dimension.filterable) {
1019
1192
  const filter = dimension.category === "numeric" ? { dimension: key, op: "between", from: 0, to: 1 } : dimension.category === "time" ? {
@@ -1022,13 +1195,15 @@ function compileEverything(dataset, dialect) {
1022
1195
  from: "2000-01-01",
1023
1196
  to: "2000-01-02"
1024
1197
  } : { dimension: key, op: "in", values: ["value"] };
1025
- dialect.compile(planQuery(dataset, { ...base, filters: [filter] }, context, true));
1198
+ compile(planQuery(dataset, { ...base, filters: [filter] }, context, true));
1026
1199
  }
1027
1200
  }
1028
1201
  } catch (error) {
1029
1202
  const message = error instanceof Error ? error.message : String(error);
1030
1203
  throw new Error(`Dataset ${dataset.key} does not compile for ${dialect.name}: ${message}`);
1031
1204
  }
1205
+ return texts.join(`
1206
+ `);
1032
1207
  }
1033
1208
  async function resolveOpenValues(dataset, query, tenant, now, run, dialect) {
1034
1209
  const resolved = new Map;
@@ -1123,7 +1298,7 @@ function catalogEntry(dataset) {
1123
1298
  key,
1124
1299
  label: entry.label,
1125
1300
  description: entry.description,
1126
- valueType: entry.kind !== "aggregate" ? "ratio" : entry.node.fn === "count" || entry.node.fn === "countDistinct" ? "count" : "number"
1301
+ valueType: entry.result
1127
1302
  })),
1128
1303
  dimensions: Object.entries(dataset.dimensions).map(([key, entry]) => ({
1129
1304
  key,
@@ -1133,12 +1308,31 @@ function catalogEntry(dataset) {
1133
1308
  category: entry.category,
1134
1309
  groupable: entry.groupable,
1135
1310
  filterable: entry.filterable,
1136
- values: entry.kind === "enum" || entry.kind === "computed" ? Object.entries(entry.values).map(([value, label]) => ({
1137
- key: value,
1138
- label
1139
- })) : undefined
1311
+ values: closedValues(entry)
1140
1312
  }))
1141
1313
  };
1142
1314
  }
1315
+ function closedValues(dimension) {
1316
+ return dimension.kind === "enum" || dimension.kind === "computed" ? Object.entries(dimension.values).map(([key, label]) => ({ key, label })) : undefined;
1317
+ }
1318
+ function resultFields(dataset, query) {
1319
+ return {
1320
+ groupBy: query.groupBy.map((key) => {
1321
+ const dimension = dataset.dimensions[key];
1322
+ const values = closedValues(dimension);
1323
+ return {
1324
+ key,
1325
+ label: dimension.label,
1326
+ kind: dimension.kind,
1327
+ ...dimension.kind === "timeBucket" && { unit: dimension.unit },
1328
+ ...values && { values }
1329
+ };
1330
+ }),
1331
+ measures: query.measures.map((key) => {
1332
+ const entry = dataset.measures[key];
1333
+ return { key, label: entry.label, valueType: entry.result };
1334
+ })
1335
+ };
1336
+ }
1143
1337
 
1144
- export { MAX_MEASURES, AnalyticsResultSchema2, allTenants2, AnalyticsError2, isAnalyticsError2, createAnalytics2 };
1338
+ export { MAX_MEASURES, periodSchema, periodOutput, AnalyticsResultSchema2, allTenants2, AnalyticsError2, isAnalyticsError2, createAnalytics2 };