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,1292 @@
1
+ import {
2
+ rootOf,
3
+ directChildren
4
+ } from "./index-e6k30sfp.js";
5
+
6
+ // src/query.ts
7
+ import { z } from "zod";
8
+
9
+ // src/period.ts
10
+ var MAX_PERIOD_DAYS = 731;
11
+ var DEFAULT_PERIOD = { last: { days: 30 } };
12
+ var DAY_MS = 86400000;
13
+ var formatterCache = new Map;
14
+ function formatter(timezone) {
15
+ let cached = formatterCache.get(timezone);
16
+ if (!cached) {
17
+ cached = new Intl.DateTimeFormat("en-US", {
18
+ timeZone: timezone,
19
+ hourCycle: "h23",
20
+ year: "numeric",
21
+ month: "2-digit",
22
+ day: "2-digit",
23
+ hour: "2-digit",
24
+ minute: "2-digit",
25
+ second: "2-digit"
26
+ });
27
+ formatterCache.set(timezone, cached);
28
+ }
29
+ return cached;
30
+ }
31
+ function isValidTimezone(timezone) {
32
+ if (!/^[A-Za-z][A-Za-z0-9_+\-/]*$/.test(timezone))
33
+ return false;
34
+ try {
35
+ formatter(timezone);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+ function localParts(instant, timezone) {
42
+ const parts = {};
43
+ for (const part of formatter(timezone).formatToParts(instant)) {
44
+ if (part.type !== "literal")
45
+ parts[part.type] = Number(part.value);
46
+ }
47
+ return {
48
+ year: parts.year ?? 0,
49
+ month: parts.month ?? 1,
50
+ day: parts.day ?? 1,
51
+ hour: parts.hour ?? 0,
52
+ minute: parts.minute ?? 0,
53
+ second: parts.second ?? 0
54
+ };
55
+ }
56
+ function offsetAt(instant, timezone) {
57
+ const p = localParts(instant, timezone);
58
+ const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
59
+ return asUtc - (instant.getTime() - instant.getUTCMilliseconds());
60
+ }
61
+ function localDate(instant, timezone) {
62
+ const p = localParts(instant, timezone);
63
+ return formatDate(Date.UTC(p.year, p.month - 1, p.day));
64
+ }
65
+ function startOfLocalDay(date, timezone) {
66
+ const wallClock = parseDate(date);
67
+ const firstGuess = wallClock - offsetAt(new Date(wallClock), timezone);
68
+ const corrected = wallClock - offsetAt(new Date(firstGuess), timezone);
69
+ return new Date(corrected);
70
+ }
71
+ function parseDate(date) {
72
+ const [year, month, day] = date.split("-").map(Number);
73
+ return Date.UTC(year ?? 0, (month ?? 1) - 1, day ?? 1);
74
+ }
75
+ function formatDate(utcMs) {
76
+ return new Date(utcMs).toISOString().slice(0, 10);
77
+ }
78
+ function addDays(date, days) {
79
+ return formatDate(parseDate(date) + days * DAY_MS);
80
+ }
81
+ function daysBetween(from, to) {
82
+ return Math.round((parseDate(to) - parseDate(from)) / DAY_MS);
83
+ }
84
+ function isoWeekday(date) {
85
+ const day = new Date(parseDate(date)).getUTCDay();
86
+ return day === 0 ? 7 : day;
87
+ }
88
+
89
+ class PeriodError extends Error {
90
+ name = "PeriodError";
91
+ }
92
+ function resolvePeriod(input, timezone, now) {
93
+ const today = localDate(now, timezone);
94
+ const [from, to] = periodDays(input, today);
95
+ if (daysBetween(from, to) < 0) {
96
+ throw new PeriodError(`The period starts (${from}) after it ends (${to}).`);
97
+ }
98
+ return periodFromDays(from, to, timezone);
99
+ }
100
+ function periodDays(input, today) {
101
+ if ("last" in input)
102
+ return [addDays(today, -(input.last.days - 1)), today];
103
+ if ("from" in input)
104
+ return [input.from, input.to];
105
+ const monthStart = `${today.slice(0, 8)}01`;
106
+ const weekStart = addDays(today, -(isoWeekday(today) - 1));
107
+ switch (input.preset) {
108
+ case "today":
109
+ return [today, today];
110
+ case "yesterday":
111
+ return [addDays(today, -1), addDays(today, -1)];
112
+ case "thisWeek":
113
+ return [weekStart, today];
114
+ case "lastWeek":
115
+ return [addDays(weekStart, -7), addDays(weekStart, -1)];
116
+ case "thisMonth":
117
+ return [monthStart, today];
118
+ case "lastMonth": {
119
+ const lastMonthEnd = addDays(monthStart, -1);
120
+ return [`${lastMonthEnd.slice(0, 8)}01`, lastMonthEnd];
121
+ }
122
+ case "thisYear":
123
+ return [`${today.slice(0, 4)}-01-01`, today];
124
+ }
125
+ }
126
+ function periodFromDays(from, to, timezone) {
127
+ const days = daysBetween(from, to) + 1;
128
+ if (days > MAX_PERIOD_DAYS) {
129
+ throw new PeriodError(`A period may cover at most ${MAX_PERIOD_DAYS} days (two years); ${from} to ${to} is ${days} days.`);
130
+ }
131
+ return {
132
+ from,
133
+ to,
134
+ timezone,
135
+ start: startOfLocalDay(from, timezone),
136
+ end: startOfLocalDay(addDays(to, 1), timezone),
137
+ days
138
+ };
139
+ }
140
+ function previousPeriod(period) {
141
+ const to = addDays(period.from, -1);
142
+ const from = addDays(to, -(period.days - 1));
143
+ return periodFromDays(from, to, period.timezone);
144
+ }
145
+
146
+ // src/query.ts
147
+ var MAX_MEASURES = 4;
148
+ var MAX_GROUP_BY = 2;
149
+ var MAX_LIMIT = 100;
150
+ var DEFAULT_LIMIT = 20;
151
+ var PERIOD_PRESETS = [
152
+ "today",
153
+ "yesterday",
154
+ "thisWeek",
155
+ "lastWeek",
156
+ "thisMonth",
157
+ "lastMonth",
158
+ "thisYear"
159
+ ];
160
+ var isoDate = z.iso.date().describe("A date, YYYY-MM-DD.");
161
+ var periodSchema = z.union([
162
+ z.object({
163
+ last: z.object({
164
+ days: z.number().int().min(1).max(MAX_PERIOD_DAYS).describe("Number of days, ending today.")
165
+ })
166
+ }),
167
+ z.object({ from: isoDate, to: isoDate.describe("Last day, inclusive.") }),
168
+ z.object({ preset: z.enum(PERIOD_PRESETS) })
169
+ ]).describe("Which days to count, in the organization timezone. Defaults to the last 30 days. At most two years.");
170
+ function describeList(entries) {
171
+ return entries.map(([key, entry]) => `${key}: ${entry.label}${entry.description ? ` (${entry.description})` : ""}`).join(" · ");
172
+ }
173
+ function nonEmpty(values) {
174
+ const [first, ...rest] = values;
175
+ return first === undefined ? null : [first, ...rest];
176
+ }
177
+ function filterSchema(key, dim) {
178
+ const dimension = z.literal(key);
179
+ const description = `${dim.label}${dim.description ? `: ${dim.description}` : ""}`;
180
+ const emptiness = z.object({
181
+ dimension,
182
+ op: z.enum(["isEmpty", "isNotEmpty"])
183
+ });
184
+ switch (dim.category) {
185
+ case "closed": {
186
+ const labels = Object.entries(dim.values).map(([value, label]) => [value, { label }]);
187
+ const keys = nonEmpty(labels.map(([value]) => value));
188
+ if (!keys)
189
+ throw new Error(`Dimension ${key} lists no values`);
190
+ const values = z.array(z.enum(keys)).min(1).describe(describeList(labels));
191
+ return z.discriminatedUnion("op", [
192
+ z.object({ dimension, op: z.enum(["in", "notIn"]), values }),
193
+ emptiness
194
+ ]).describe(description);
195
+ }
196
+ case "open":
197
+ return z.discriminatedUnion("op", [
198
+ z.object({
199
+ dimension,
200
+ op: z.enum(["in", "notIn"]),
201
+ values: z.array(z.string()).min(1).describe("Keys or names; names match case-insensitively.")
202
+ }),
203
+ emptiness
204
+ ]).describe(description);
205
+ case "numeric":
206
+ return z.discriminatedUnion("op", [
207
+ z.object({
208
+ dimension,
209
+ op: z.literal("between"),
210
+ from: z.number(),
211
+ to: z.number()
212
+ }),
213
+ emptiness
214
+ ]).describe(`${description} (inclusive range)`);
215
+ case "time":
216
+ return z.discriminatedUnion("op", [
217
+ z.object({
218
+ dimension,
219
+ op: z.literal("between"),
220
+ from: isoDate,
221
+ to: isoDate
222
+ }),
223
+ emptiness
224
+ ]).describe(`${description} (bucket start dates, inclusive)`);
225
+ }
226
+ }
227
+ function datasetQuerySchema(dataset) {
228
+ const measures = Object.entries(dataset.measures);
229
+ const dimensions = Object.entries(dataset.dimensions);
230
+ const groupable = dimensions.filter(([, dim]) => dim.groupable);
231
+ const filterable = dimensions.filter(([, dim]) => dim.filterable);
232
+ const measureKeys = nonEmpty(measures.map(([key]) => key));
233
+ if (!measureKeys)
234
+ throw new Error(`Dataset ${dataset.key} has no measures`);
235
+ const groupKeys = nonEmpty(groupable.map(([key]) => key));
236
+ const timeKeys = nonEmpty(Object.keys(dataset.time));
237
+ const sortKeys = nonEmpty([...measureKeys, ...groupKeys ?? []]) ?? measureKeys;
238
+ const notes = groupable.filter(([, dim]) => dim.kind === "manyToMany" && dim.note).map(([key, dim]) => `${key}: ${dim.kind === "manyToMany" ? dim.note : ""}`);
239
+ const [firstFilter, ...otherFilters] = filterable.map(([key, dim]) => filterSchema(key, dim));
240
+ return z.object({
241
+ dataset: z.literal(dataset.key).describe(`${dataset.label}. ${dataset.description}`),
242
+ measures: z.array(z.enum(measureKeys)).min(1).max(MAX_MEASURES).describe(describeList(measures)),
243
+ ...groupKeys && {
244
+ groupBy: z.array(z.enum(groupKeys)).max(MAX_GROUP_BY).optional().describe([describeList(groupable), ...notes].join(`
245
+ `))
246
+ },
247
+ ...firstFilter && {
248
+ filters: z.array(z.discriminatedUnion("dimension", [firstFilter, ...otherFilters])).optional().describe("Every filter must match.")
249
+ },
250
+ period: periodSchema.nullish(),
251
+ ...timeKeys && {
252
+ time: z.enum(timeKeys).optional().describe(`Which time field the period applies to; time dimensions name their own. Defaults to ${dataset.defaultTime}. ${describeList(Object.entries(dataset.time))}`)
253
+ },
254
+ compareToPrevious: z.boolean().optional().describe("Also return the previous period of the same length."),
255
+ sort: z.object({
256
+ by: z.enum(sortKeys),
257
+ direction: z.enum(["asc", "desc"]).optional()
258
+ }).optional().describe("One of the asked measures or grouped dimensions. Defaults to time order when grouped by time, otherwise the first measure, descending."),
259
+ limit: z.number().int().min(1).max(MAX_LIMIT).optional().describe(`Maximum number of groups. Defaults to ${DEFAULT_LIMIT}.`)
260
+ }).superRefine((query, context) => {
261
+ const { groupBy } = query;
262
+ const by = query.sort?.by;
263
+ const returned = [...query.measures, ...groupBy ?? []];
264
+ if (by !== undefined && !returned.includes(by)) {
265
+ context.addIssue({
266
+ code: "custom",
267
+ path: ["sort", "by"],
268
+ message: `Sort by one of the asked measures or grouped dimensions: ${returned.join(", ")}`
269
+ });
270
+ }
271
+ });
272
+ }
273
+ function buildQuerySchema(datasets) {
274
+ const [first, ...rest] = datasets.map(datasetQuerySchema);
275
+ if (!first)
276
+ throw new Error("querySchema needs at least one dataset");
277
+ return z.discriminatedUnion("dataset", [first, ...rest]);
278
+ }
279
+ var periodOutput = z.object({
280
+ from: z.string(),
281
+ to: z.string(),
282
+ timezone: z.string()
283
+ });
284
+ var measureValue = z.number().nullable();
285
+ var dimensionValue = z.object({
286
+ key: z.union([z.string(), z.number()]).nullable(),
287
+ label: z.string()
288
+ });
289
+ function resultSchemaOf(row, totals) {
290
+ return z.object({
291
+ period: periodOutput,
292
+ rows: z.array(row),
293
+ totals,
294
+ previous: z.object({ period: periodOutput, rows: z.array(row), totals }).optional(),
295
+ notes: z.array(z.string()),
296
+ truncated: z.boolean()
297
+ });
298
+ }
299
+ function datasetResultSchema(dataset) {
300
+ const measures = Object.fromEntries(Object.entries(dataset.measures).map(([key, entry]) => [
301
+ key,
302
+ measureValue.optional().describe(entry.label)
303
+ ]));
304
+ const dimensions = Object.fromEntries(Object.entries(dataset.dimensions).map(([key, entry]) => [
305
+ key,
306
+ dimensionValue.optional().describe(entry.label)
307
+ ]));
308
+ return resultSchemaOf(z.object({ ...dimensions, ...measures }), z.object(measures));
309
+ }
310
+ var AnalyticsResultSchema2 = resultSchemaOf(z.record(z.string(), z.union([dimensionValue, measureValue])), z.record(z.string(), measureValue));
311
+
312
+ // src/analytics.ts
313
+ import { z as z2 } from "zod";
314
+
315
+ // src/plan.ts
316
+ function normalizeQuery(dataset, parsed) {
317
+ const filters = (parsed.filters ?? []).map((filter) => filter.op === "in" || filter.op === "notIn" ? { ...filter, values: [...new Set(filter.values)] } : filter);
318
+ return {
319
+ dataset: dataset.key,
320
+ measures: [...new Set(parsed.measures)],
321
+ groupBy: [...new Set(parsed.groupBy ?? [])],
322
+ filters,
323
+ period: parsed.period ?? DEFAULT_PERIOD,
324
+ time: parsed.time ?? dataset.defaultTime,
325
+ compareToPrevious: parsed.compareToPrevious ?? false,
326
+ sort: parsed.sort ? { by: parsed.sort.by, direction: parsed.sort.direction ?? "desc" } : null,
327
+ limit: parsed.limit ?? DEFAULT_LIMIT
328
+ };
329
+ }
330
+ var param = (value) => ({ kind: "param", value });
331
+ var and = (items) => items.length === 1 && items[0] ? items[0] : { kind: "logical", op: "and", items };
332
+ function measureOf(dataset, key) {
333
+ const entry = dataset.measures[key];
334
+ if (!entry)
335
+ throw new Error(`Dataset ${dataset.key} has no measure ${key}`);
336
+ return entry;
337
+ }
338
+ function dimensionOf(dataset, key) {
339
+ const entry = dataset.dimensions[key];
340
+ if (!entry)
341
+ throw new Error(`Dataset ${dataset.key} has no dimension ${key}`);
342
+ return entry;
343
+ }
344
+ function measureNode(dataset, key) {
345
+ const entry = measureOf(dataset, key);
346
+ switch (entry.kind) {
347
+ case "aggregate":
348
+ return entry.node;
349
+ case "share": {
350
+ const numeratorWhere = entry.denominator ? and([entry.numerator, entry.denominator]) : entry.numerator;
351
+ return {
352
+ kind: "arithmetic",
353
+ op: "/",
354
+ left: {
355
+ kind: "aggregate",
356
+ fn: "count",
357
+ arg: null,
358
+ where: numeratorWhere
359
+ },
360
+ right: {
361
+ kind: "aggregate",
362
+ fn: "count",
363
+ arg: null,
364
+ where: entry.denominator
365
+ }
366
+ };
367
+ }
368
+ case "ratio":
369
+ return {
370
+ kind: "arithmetic",
371
+ op: "/",
372
+ left: measureNode(dataset, entry.numerator),
373
+ right: measureNode(dataset, entry.denominator)
374
+ };
375
+ }
376
+ }
377
+ function dimensionNodes(dataset, key, timezone) {
378
+ const entry = dimensionOf(dataset, key);
379
+ switch (entry.kind) {
380
+ case "enum":
381
+ case "computed":
382
+ case "number":
383
+ return { key: entry.expr, label: null, virtualJoin: null };
384
+ case "relation":
385
+ return { key: entry.key, label: entry.name, virtualJoin: null };
386
+ case "manyToMany":
387
+ return {
388
+ key: entry.key,
389
+ label: entry.name,
390
+ virtualJoin: { table: entry.table, type: "left", on: entry.on }
391
+ };
392
+ case "timeBucket":
393
+ case "timePart": {
394
+ const field = dataset.time[entry.time];
395
+ if (!field)
396
+ throw new Error(`Dataset ${dataset.key} has no time field ${entry.time}`);
397
+ return {
398
+ key: entry.kind === "timeBucket" ? {
399
+ kind: "timeBucket",
400
+ unit: entry.unit,
401
+ item: field.column,
402
+ timezone
403
+ } : {
404
+ kind: "timePart",
405
+ part: entry.part,
406
+ item: field.column,
407
+ timezone
408
+ },
409
+ label: null,
410
+ virtualJoin: null
411
+ };
412
+ }
413
+ }
414
+ }
415
+ function baseWhere(dataset, tenant, period) {
416
+ const where = [
417
+ {
418
+ kind: "compare",
419
+ op: "=",
420
+ left: dataset.tenantColumn,
421
+ right: param(tenant)
422
+ }
423
+ ];
424
+ if (dataset.scope)
425
+ where.push(dataset.scope);
426
+ if (period) {
427
+ const field = dataset.time[period.time];
428
+ if (!field)
429
+ throw new Error(`Dataset ${dataset.key} has no time field ${period.time}`);
430
+ where.push({
431
+ kind: "compare",
432
+ op: ">=",
433
+ left: field.column,
434
+ right: param(period.start)
435
+ }, { kind: "compare", op: "<", left: field.column, right: param(period.end) });
436
+ }
437
+ return where;
438
+ }
439
+ function filterNode(dataset, filter, context) {
440
+ const entry = dimensionOf(dataset, filter.dimension);
441
+ const { key } = dimensionNodes(dataset, filter.dimension, context.timezone);
442
+ if (entry.kind === "manyToMany") {
443
+ const linked = (condition, negated) => ({
444
+ kind: "exists",
445
+ table: entry.table,
446
+ where: condition ? and([entry.on, condition]) : entry.on,
447
+ negated
448
+ });
449
+ switch (filter.op) {
450
+ case "isEmpty":
451
+ case "isNotEmpty":
452
+ return linked(null, filter.op === "isEmpty");
453
+ case "between":
454
+ throw new Error(`${filter.dimension} does not support between`);
455
+ case "in":
456
+ case "notIn": {
457
+ const values = context.resolvedValues.get(filter.dimension) ?? filter.values;
458
+ const matches = {
459
+ kind: "in",
460
+ item: key,
461
+ values: values.map(param),
462
+ negated: false
463
+ };
464
+ return linked(matches, filter.op === "notIn");
465
+ }
466
+ }
467
+ }
468
+ switch (filter.op) {
469
+ case "isEmpty":
470
+ return { kind: "isNull", item: key, negated: false };
471
+ case "isNotEmpty":
472
+ return { kind: "isNull", item: key, negated: true };
473
+ case "between":
474
+ return and([
475
+ { kind: "compare", op: ">=", left: key, right: param(filter.from) },
476
+ { kind: "compare", op: "<=", left: key, right: param(filter.to) }
477
+ ]);
478
+ case "in":
479
+ case "notIn": {
480
+ const values = context.resolvedValues.get(filter.dimension) ?? filter.values;
481
+ const list = {
482
+ kind: "in",
483
+ item: key,
484
+ values: values.map(param),
485
+ negated: filter.op === "notIn"
486
+ };
487
+ if (filter.op === "in")
488
+ return list;
489
+ return {
490
+ kind: "logical",
491
+ op: "or",
492
+ items: [list, { kind: "isNull", item: key, negated: false }]
493
+ };
494
+ }
495
+ }
496
+ }
497
+ var columnAlias = {
498
+ measure: (key) => `m_${key}`,
499
+ groupKey: (key) => `g_${key}`,
500
+ groupLabel: (key) => `g_${key}__label`
501
+ };
502
+ function planQuery(dataset, query, context, grouped) {
503
+ const select = [];
504
+ const groupBy = [];
505
+ const virtualJoins = [];
506
+ const groupKeys = grouped ? query.groupBy : [];
507
+ for (const key of groupKeys) {
508
+ const nodes = dimensionNodes(dataset, key, context.timezone);
509
+ if (nodes.virtualJoin)
510
+ virtualJoins.push(nodes.virtualJoin);
511
+ groupBy.push(select.length);
512
+ select.push({ alias: columnAlias.groupKey(key), node: nodes.key });
513
+ if (nodes.label) {
514
+ groupBy.push(select.length);
515
+ select.push({ alias: columnAlias.groupLabel(key), node: nodes.label });
516
+ }
517
+ }
518
+ for (const key of query.measures) {
519
+ select.push({
520
+ alias: columnAlias.measure(key),
521
+ node: measureNode(dataset, key)
522
+ });
523
+ }
524
+ const where = [
525
+ ...baseWhere(dataset, context.tenant, {
526
+ time: query.time,
527
+ start: context.period.start,
528
+ end: context.period.end
529
+ }),
530
+ ...query.filters.map((filter) => filterNode(dataset, filter, context))
531
+ ];
532
+ return {
533
+ from: dataset.from,
534
+ virtualJoins,
535
+ where,
536
+ select,
537
+ groupBy,
538
+ orderBy: groupKeys.length > 0 ? orderBy(dataset, query, select) : [],
539
+ limit: grouped && groupKeys.length > 0 ? query.limit + 1 : null,
540
+ now: context.now
541
+ };
542
+ }
543
+ function orderBy(dataset, query, select) {
544
+ const indexOf = (alias) => select.findIndex((item) => item.alias === alias);
545
+ const order = [];
546
+ const timeKey = query.groupBy.find((key) => {
547
+ const kind = dimensionOf(dataset, key).kind;
548
+ return kind === "timeBucket" || kind === "timePart";
549
+ });
550
+ const sort = query.sort ?? (timeKey ? { by: timeKey, direction: "asc" } : { by: query.measures[0] ?? "", direction: "desc" });
551
+ const sortIndex = sort.by in dataset.measures ? indexOf(columnAlias.measure(sort.by)) : indexOf(columnAlias.groupKey(sort.by));
552
+ if (sortIndex >= 0)
553
+ order.push({ index: sortIndex, direction: sort.direction });
554
+ for (const key of query.groupBy) {
555
+ const index = indexOf(columnAlias.groupKey(key));
556
+ if (index >= 0 && index !== sortIndex)
557
+ order.push({ index, direction: "asc" });
558
+ }
559
+ return order;
560
+ }
561
+ function planLookup(dataset, dimensionKey, tenant, values, now) {
562
+ const nodes = dimensionNodes(dataset, dimensionKey, "UTC");
563
+ const label = nodes.label ?? nodes.key;
564
+ const where = baseWhere(dataset, tenant, null);
565
+ where.push({ kind: "isNull", item: nodes.key, negated: true });
566
+ if (values) {
567
+ where.push({
568
+ kind: "logical",
569
+ op: "or",
570
+ items: [
571
+ {
572
+ kind: "in",
573
+ item: { kind: "toText", item: nodes.key },
574
+ values: values.map(param),
575
+ negated: false
576
+ },
577
+ {
578
+ kind: "in",
579
+ item: { kind: "lower", item: label },
580
+ values: values.map((value) => param(value.toLowerCase())),
581
+ negated: false
582
+ }
583
+ ]
584
+ });
585
+ }
586
+ return {
587
+ from: dataset.from,
588
+ virtualJoins: nodes.virtualJoin ? [nodes.virtualJoin] : [],
589
+ where,
590
+ select: [
591
+ { alias: "key", node: nodes.key },
592
+ { alias: "label", node: label }
593
+ ],
594
+ groupBy: [0, 1],
595
+ orderBy: [{ index: 1, direction: "asc" }],
596
+ limit: values ? 500 : 50,
597
+ now
598
+ };
599
+ }
600
+ var WEEKDAYS = [
601
+ "Monday",
602
+ "Tuesday",
603
+ "Wednesday",
604
+ "Thursday",
605
+ "Friday",
606
+ "Saturday",
607
+ "Sunday"
608
+ ];
609
+ function decodeKey(dimension, raw, dialect) {
610
+ if (raw === null || raw === undefined)
611
+ return null;
612
+ switch (dimension.kind) {
613
+ case "number":
614
+ case "timePart":
615
+ return dialect.decodeNumber(raw);
616
+ case "relation":
617
+ case "manyToMany":
618
+ if (typeof raw === "string")
619
+ return raw;
620
+ return dialect.decodeNumber(raw) ?? String(raw);
621
+ default:
622
+ return String(raw);
623
+ }
624
+ }
625
+ function labelOf(dimension, key, rawLabel) {
626
+ switch (dimension.kind) {
627
+ case "enum":
628
+ case "computed":
629
+ return key === null ? dimension.empty ?? "None" : dimension.values[String(key)] ?? String(key);
630
+ case "relation":
631
+ case "manyToMany":
632
+ if (key === null)
633
+ return dimension.empty ?? "None";
634
+ return rawLabel === null || rawLabel === undefined ? String(key) : String(rawLabel);
635
+ case "timeBucket":
636
+ if (key === null)
637
+ return "None";
638
+ return dimension.labelFor?.(String(key)) ?? String(key);
639
+ case "timePart":
640
+ if (typeof key !== "number")
641
+ return "None";
642
+ if (dimension.labelFor)
643
+ return dimension.labelFor(key);
644
+ return dimension.part === "isodow" ? WEEKDAYS[key - 1] ?? String(key) : `${String(key).padStart(2, "0")}:00`;
645
+ case "number":
646
+ if (typeof key !== "number")
647
+ return "None";
648
+ return dimension.labelFor?.(key) ?? String(key);
649
+ }
650
+ }
651
+ function shapeMeasures(dataset, measures, raw, dialect) {
652
+ const values = {};
653
+ for (const key of measures) {
654
+ const value = dialect.decodeNumber(raw[columnAlias.measure(key)]);
655
+ values[key] = measureOf(dataset, key).result === "count" ? value ?? 0 : value;
656
+ }
657
+ return values;
658
+ }
659
+ function shapeRows(dataset, query, rawRows, dialect) {
660
+ const truncated = query.groupBy.length > 0 && rawRows.length > query.limit;
661
+ const kept = truncated ? rawRows.slice(0, query.limit) : rawRows;
662
+ const rows = kept.map((raw) => {
663
+ const row = {};
664
+ for (const key of query.groupBy) {
665
+ const dimension = dimensionOf(dataset, key);
666
+ const value = decodeKey(dimension, raw[columnAlias.groupKey(key)], dialect);
667
+ row[key] = {
668
+ key: value,
669
+ label: labelOf(dimension, value, raw[columnAlias.groupLabel(key)])
670
+ };
671
+ }
672
+ return { ...row, ...shapeMeasures(dataset, query.measures, raw, dialect) };
673
+ });
674
+ return { rows, truncated };
675
+ }
676
+ function resultNotes(dataset, query) {
677
+ const notes = [];
678
+ for (const key of query.groupBy) {
679
+ const dimension = dimensionOf(dataset, key);
680
+ if (dimension.kind !== "manyToMany")
681
+ continue;
682
+ notes.push(dimension.note ?? `A row with several ${dimension.label.toLowerCase()} values counts once per value, so ${dimension.label.toLowerCase()} rows can add up to more than the total.`);
683
+ }
684
+ return notes;
685
+ }
686
+
687
+ // src/analytics.ts
688
+ class AnalyticsError2 extends Error {
689
+ code;
690
+ name = "AnalyticsError";
691
+ details;
692
+ constructor(code, message, ...[details]) {
693
+ super(message);
694
+ this.code = code;
695
+ this.details = details;
696
+ }
697
+ }
698
+ function isAnalyticsError2(error, code) {
699
+ return error instanceof AnalyticsError2 && (code === undefined || error.code === code);
700
+ }
701
+ function createAnalytics2(options) {
702
+ const datasets = options.datasets;
703
+ const sourceNames = Object.keys(options.sources);
704
+ const byKey = new Map;
705
+ const sourceOf = new Map;
706
+ for (const dataset of datasets) {
707
+ if (byKey.has(dataset.key))
708
+ throw new Error(`Two datasets use the key ${dataset.key}`);
709
+ byKey.set(dataset.key, dataset);
710
+ const source = resolveSource(dataset, options.sources, sourceNames);
711
+ sourceOf.set(dataset.key, source);
712
+ compileEverything(dataset, source.dialect);
713
+ }
714
+ const now = options.now ?? (() => new Date);
715
+ const querySchemas = new Map;
716
+ const resultSchemas = new Map;
717
+ const datasetOf = (key) => {
718
+ const dataset = byKey.get(key);
719
+ if (!dataset)
720
+ throw new AnalyticsError2("invalid_query", `Unknown dataset ${key}`, []);
721
+ return dataset;
722
+ };
723
+ const sourceFor = (dataset) => {
724
+ const source = sourceOf.get(dataset.key);
725
+ if (!source)
726
+ throw new Error(`No source for ${dataset.key}`);
727
+ return source;
728
+ };
729
+ const querySchemaFor = (keys) => {
730
+ const selected = keys ? keys.map(datasetOf) : datasets;
731
+ const selectionKey = selected.map((dataset) => dataset.key).sort().join(",");
732
+ let schema = querySchemas.get(selectionKey);
733
+ if (!schema) {
734
+ schema = buildQuerySchema(selected);
735
+ querySchemas.set(selectionKey, schema);
736
+ }
737
+ return schema;
738
+ };
739
+ const resultSchemaFor = (key) => {
740
+ let schema = resultSchemas.get(key);
741
+ if (!schema) {
742
+ schema = datasetResultSchema(datasetOf(key));
743
+ resultSchemas.set(key, schema);
744
+ }
745
+ return schema;
746
+ };
747
+ const allowedDatasets = async (ctx) => {
748
+ const { authorize } = options;
749
+ if (!authorize)
750
+ return datasets;
751
+ const allowed = await Promise.all(datasets.map((dataset) => authorize(dataset, ctx)));
752
+ return datasets.filter((_, index) => allowed[index]);
753
+ };
754
+ const querySchemaForCtx = async (ctx) => {
755
+ const allowed = await allowedDatasets(ctx);
756
+ if (allowed.length === 0) {
757
+ throw new AnalyticsError2("forbidden", "No dataset is allowed");
758
+ }
759
+ return querySchemaFor(allowed.map((dataset) => dataset.key));
760
+ };
761
+ const prepare = async (input, ctx) => {
762
+ const parsed = querySchemaFor(undefined).safeParse(input);
763
+ if (!parsed.success) {
764
+ throw new AnalyticsError2("invalid_query", `Invalid analytics query: ${z2.prettifyError(parsed.error)}`, parsed.error.issues);
765
+ }
766
+ const dataset = datasetOf(parsed.data.dataset);
767
+ if (options.authorize && !await options.authorize(dataset, ctx)) {
768
+ throw new AnalyticsError2("forbidden", `Not allowed to query ${dataset.key}`);
769
+ }
770
+ const tenant = options.tenant(ctx);
771
+ if (tenant === null || tenant === undefined || tenant === "") {
772
+ throw new AnalyticsError2("missing_tenant", "Refusing to query without a tenant");
773
+ }
774
+ const timezone = await options.timezone?.(ctx) || "UTC";
775
+ if (!isValidTimezone(timezone)) {
776
+ throw new AnalyticsError2("invalid_timezone", `Unknown timezone ${timezone}`);
777
+ }
778
+ const query = normalizeQuery(dataset, parsed.data);
779
+ const at = now();
780
+ let period;
781
+ try {
782
+ period = 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
+ }
789
+ const previous = query.compareToPrevious ? previousPeriod(period) : null;
790
+ return { dataset, query, tenant, timezone, period, previous, now: at };
791
+ };
792
+ const runQuery = async (input, ctx) => {
793
+ const { dataset, query, tenant, timezone, period, previous, now } = await prepare(input, ctx);
794
+ const source = sourceFor(dataset);
795
+ const { dialect } = source;
796
+ const run = (statement) => source.execute(dialect.compile(statement));
797
+ const resolvedValues = await resolveOpenValues(dataset, query, tenant, now, run, dialect);
798
+ const runPeriod = async (target) => {
799
+ const statements = periodStatements(dataset, query, {
800
+ tenant,
801
+ timezone,
802
+ period: target,
803
+ now,
804
+ resolvedValues
805
+ });
806
+ const [rawRows, rawTotals] = await Promise.all([
807
+ run(statements.rows),
808
+ statements.totals ? run(statements.totals) : null
809
+ ]);
810
+ const { rows, truncated } = shapeRows(dataset, query, rawRows, dialect);
811
+ const totalsRow = (rawTotals ?? rawRows)[0] ?? {};
812
+ const totals = shapeMeasures(dataset, query.measures, totalsRow, dialect);
813
+ const periodOut = { from: target.from, to: target.to, timezone };
814
+ return { period: periodOut, rows, totals, truncated };
815
+ };
816
+ const [current, before] = await Promise.all([
817
+ runPeriod(period),
818
+ previous ? runPeriod(previous) : null
819
+ ]);
820
+ return {
821
+ period: current.period,
822
+ rows: current.rows,
823
+ totals: current.totals,
824
+ ...before && {
825
+ previous: {
826
+ period: before.period,
827
+ rows: before.rows,
828
+ totals: before.totals
829
+ }
830
+ },
831
+ notes: resultNotes(dataset, query),
832
+ truncated: current.truncated
833
+ };
834
+ };
835
+ const datasetKeyOf = (input) => {
836
+ if (typeof input !== "object" || input === null)
837
+ return null;
838
+ const key = "dataset" in input ? input.dataset : undefined;
839
+ return typeof key === "string" && byKey.has(key) ? key : null;
840
+ };
841
+ const report = (event, ctx) => {
842
+ try {
843
+ Promise.resolve(options.onQuery?.(event, ctx)).catch(() => {});
844
+ } catch {}
845
+ };
846
+ const query = async (input, ctx) => {
847
+ const startedAt = performance.now();
848
+ const dataset = datasetKeyOf(input);
849
+ const durationMs = () => Math.round(performance.now() - startedAt);
850
+ try {
851
+ const result = await runQuery(input, ctx);
852
+ report({
853
+ dataset,
854
+ durationMs: durationMs(),
855
+ rows: result.rows.length,
856
+ outcome: "success"
857
+ }, ctx);
858
+ return result;
859
+ } catch (error) {
860
+ report({ dataset, durationMs: durationMs(), rows: 0, outcome: "error", error }, ctx);
861
+ throw error;
862
+ }
863
+ };
864
+ const explain = async (input, ctx) => {
865
+ const { dataset, query, tenant, timezone, period, previous, now } = await prepare(input, ctx);
866
+ const { dialect } = sourceFor(dataset);
867
+ const compiled = [];
868
+ for (const target of previous ? [period, previous] : [period]) {
869
+ const statements = periodStatements(dataset, query, {
870
+ tenant,
871
+ timezone,
872
+ period: target,
873
+ now,
874
+ resolvedValues: new Map
875
+ });
876
+ compiled.push(dialect.compile(statements.rows));
877
+ if (statements.totals)
878
+ compiled.push(dialect.compile(statements.totals));
879
+ }
880
+ return compiled;
881
+ };
882
+ return {
883
+ datasets: options.datasets,
884
+ query,
885
+ explain,
886
+ querySchema: (schemaOptions) => schemaOptions && ("ctx" in schemaOptions) ? querySchemaForCtx(schemaOptions.ctx) : querySchemaFor(schemaOptions?.datasets),
887
+ resultSchema: (key) => resultSchemaFor(key),
888
+ listDatasets: async (ctx) => (await allowedDatasets(ctx)).map((dataset) => catalogEntry(dataset))
889
+ };
890
+ }
891
+ function periodStatements(dataset, query, context) {
892
+ const grouped = query.groupBy.length > 0;
893
+ return {
894
+ rows: planQuery(dataset, query, context, true),
895
+ totals: grouped ? planQuery(dataset, query, context, false) : null
896
+ };
897
+ }
898
+ function resolveSource(dataset, sources, sourceNames) {
899
+ const name = dataset.source ?? (sourceNames.length === 1 ? sourceNames[0] : undefined);
900
+ if (!name) {
901
+ throw new Error(`Dataset ${dataset.key} names no source, and there are several: ${sourceNames.join(", ")}`);
902
+ }
903
+ const source = sources[name];
904
+ if (!source)
905
+ throw new Error(`Dataset ${dataset.key} uses unknown source ${name}`);
906
+ return source;
907
+ }
908
+ function compileEverything(dataset, dialect) {
909
+ const now = new Date(0);
910
+ const context = {
911
+ tenant: "tenant",
912
+ timezone: "UTC",
913
+ period: resolvePeriod({ last: { days: 1 } }, "UTC", now),
914
+ now,
915
+ resolvedValues: new Map
916
+ };
917
+ const measures = Object.keys(dataset.measures);
918
+ const base = normalizeQuery(dataset, { dataset: dataset.key, measures });
919
+ try {
920
+ dialect.compile(planQuery(dataset, base, context, true));
921
+ for (const [key, dimension] of Object.entries(dataset.dimensions)) {
922
+ if (dimension.groupable) {
923
+ dialect.compile(planQuery(dataset, { ...base, groupBy: [key] }, context, true));
924
+ }
925
+ if (dimension.filterable) {
926
+ const filter = dimension.category === "numeric" ? { dimension: key, op: "between", from: 0, to: 1 } : dimension.category === "time" ? {
927
+ dimension: key,
928
+ op: "between",
929
+ from: "2000-01-01",
930
+ to: "2000-01-02"
931
+ } : { dimension: key, op: "in", values: ["value"] };
932
+ dialect.compile(planQuery(dataset, { ...base, filters: [filter] }, context, true));
933
+ }
934
+ }
935
+ } catch (error) {
936
+ const message = error instanceof Error ? error.message : String(error);
937
+ throw new Error(`Dataset ${dataset.key} does not compile for ${dialect.name}: ${message}`);
938
+ }
939
+ }
940
+ async function resolveOpenValues(dataset, query, tenant, now, run, dialect) {
941
+ const resolved = new Map;
942
+ for (const filter of query.filters) {
943
+ if (filter.op !== "in" && filter.op !== "notIn")
944
+ continue;
945
+ const dimension = dataset.dimensions[filter.dimension];
946
+ if (dimension?.category !== "open")
947
+ continue;
948
+ const wanted = filter.values.map(String);
949
+ const found = await run(planLookup(dataset, filter.dimension, tenant, wanted, now));
950
+ const keys = new Set;
951
+ const unknown = [];
952
+ for (const value of wanted) {
953
+ const matches = found.filter((row) => String(row.key) === value || String(row.label ?? "").toLowerCase() === value.toLowerCase());
954
+ if (matches.length === 0)
955
+ unknown.push(value);
956
+ for (const match of matches)
957
+ keys.add(decodeKey2(match.key, dialect));
958
+ }
959
+ if (unknown.length > 0) {
960
+ const valid = (await run(planLookup(dataset, filter.dimension, tenant, null, now))).map((row) => ({
961
+ key: String(decodeKey2(row.key, dialect)),
962
+ label: String(row.label ?? row.key)
963
+ }));
964
+ const list = valid.map((entry) => `${entry.label} (${entry.key})`).join(", ");
965
+ const quoted = unknown.map((value) => `"${value}"`).join(", ");
966
+ throw new AnalyticsError2("unknown_value", `Unknown ${dimension.label.toLowerCase()}: ${quoted}. Valid values: ${list || "none"}.`, { dimension: filter.dimension, unknown, valid });
967
+ }
968
+ resolved.set(filter.dimension, [...keys]);
969
+ }
970
+ return resolved;
971
+ }
972
+ function decodeKey2(value, dialect) {
973
+ if (typeof value === "string")
974
+ return value;
975
+ return dialect.decodeNumber(value) ?? String(value);
976
+ }
977
+ function catalogEntry(dataset) {
978
+ return {
979
+ key: dataset.key,
980
+ label: dataset.label,
981
+ description: dataset.description,
982
+ time: Object.entries(dataset.time).map(([key, field]) => ({
983
+ key,
984
+ label: field.label
985
+ })),
986
+ measures: Object.entries(dataset.measures).map(([key, entry]) => ({
987
+ key,
988
+ label: entry.label,
989
+ description: entry.description
990
+ })),
991
+ dimensions: Object.entries(dataset.dimensions).map(([key, entry]) => ({
992
+ key,
993
+ label: entry.label,
994
+ description: entry.description,
995
+ kind: entry.kind,
996
+ category: entry.category,
997
+ groupable: entry.groupable,
998
+ filterable: entry.filterable,
999
+ values: entry.kind === "enum" || entry.kind === "computed" ? Object.entries(entry.values).map(([value, label]) => ({
1000
+ key: value,
1001
+ label
1002
+ })) : undefined
1003
+ }))
1004
+ };
1005
+ }
1006
+
1007
+ // src/dataset.ts
1008
+ function aggregate(result, fn, arg, options) {
1009
+ return {
1010
+ kind: "aggregate",
1011
+ result,
1012
+ label: options.label,
1013
+ description: options.description,
1014
+ node: {
1015
+ kind: "aggregate",
1016
+ fn,
1017
+ arg: arg?.node ?? null,
1018
+ where: options.where?.node ?? null
1019
+ }
1020
+ };
1021
+ }
1022
+ var measure2 = {
1023
+ count(options) {
1024
+ return aggregate("count", "count", null, options);
1025
+ },
1026
+ countWhere(condition, options) {
1027
+ return aggregate("count", "count", null, { ...options, where: condition });
1028
+ },
1029
+ countDistinct(expr, options) {
1030
+ return aggregate("count", "countDistinct", expr, options);
1031
+ },
1032
+ sum(expr, options) {
1033
+ return aggregate("number", "sum", expr, options);
1034
+ },
1035
+ avg(expr, options) {
1036
+ return aggregate("number", "avg", expr, options);
1037
+ },
1038
+ median(expr, options) {
1039
+ return aggregate("number", "median", expr, options);
1040
+ },
1041
+ share(options) {
1042
+ return {
1043
+ kind: "share",
1044
+ result: "ratio",
1045
+ label: options.label,
1046
+ description: options.description,
1047
+ numerator: options.numerator.node,
1048
+ denominator: options.denominator?.node ?? null
1049
+ };
1050
+ },
1051
+ ratio(numerator, denominator, options) {
1052
+ return {
1053
+ kind: "ratio",
1054
+ result: "ratio",
1055
+ label: options.label,
1056
+ description: options.description,
1057
+ numerator,
1058
+ denominator
1059
+ };
1060
+ }
1061
+ };
1062
+ function base(options) {
1063
+ return {
1064
+ label: options.label,
1065
+ description: options.description,
1066
+ groupable: options.filterOnly !== true,
1067
+ filterable: options.groupOnly !== true
1068
+ };
1069
+ }
1070
+ function withAccess(_options, dimension) {
1071
+ return dimension;
1072
+ }
1073
+ var dimension2 = {
1074
+ enum(expr, values, options) {
1075
+ for (const value of Object.values(values)) {
1076
+ if (!(value in options.labels)) {
1077
+ throw new Error(`${options.label}: the value ${value} has no label`);
1078
+ }
1079
+ }
1080
+ return withAccess(options, {
1081
+ ...base(options),
1082
+ category: "closed",
1083
+ kind: "enum",
1084
+ expr: expr.node,
1085
+ values: options.labels,
1086
+ empty: options.empty
1087
+ });
1088
+ },
1089
+ computed(options) {
1090
+ return withAccess(options, {
1091
+ ...base(options),
1092
+ category: "closed",
1093
+ kind: "computed",
1094
+ expr: options.expr.node,
1095
+ values: options.values,
1096
+ empty: options.empty
1097
+ });
1098
+ },
1099
+ relation(options) {
1100
+ return withAccess(options, {
1101
+ ...base(options),
1102
+ category: "open",
1103
+ kind: "relation",
1104
+ key: options.key.node,
1105
+ name: options.name.node,
1106
+ empty: options.empty
1107
+ });
1108
+ },
1109
+ manyToMany(options) {
1110
+ return withAccess(options, {
1111
+ ...base(options),
1112
+ category: "open",
1113
+ kind: "manyToMany",
1114
+ table: options.through.table.ref,
1115
+ on: options.through.on.node,
1116
+ key: options.through.key.node,
1117
+ name: options.through.name.node,
1118
+ empty: options.empty,
1119
+ note: options.note
1120
+ });
1121
+ },
1122
+ number(expr, options) {
1123
+ return withAccess(options, {
1124
+ ...base(options),
1125
+ category: "numeric",
1126
+ kind: "number",
1127
+ expr: expr.node,
1128
+ labelFor: options.labelFor
1129
+ });
1130
+ },
1131
+ timeBucket(time, unit, options) {
1132
+ return withAccess(options, {
1133
+ ...base({
1134
+ ...options,
1135
+ label: options?.label ?? unit[0]?.toUpperCase() + unit.slice(1)
1136
+ }),
1137
+ category: "time",
1138
+ kind: "timeBucket",
1139
+ time,
1140
+ unit,
1141
+ labelFor: options?.labelFor
1142
+ });
1143
+ },
1144
+ timePart(time, part, options) {
1145
+ return withAccess(options, {
1146
+ ...base({
1147
+ ...options,
1148
+ label: options?.label ?? (part === "isodow" ? "Weekday" : "Hour")
1149
+ }),
1150
+ category: "numeric",
1151
+ kind: "timePart",
1152
+ time,
1153
+ part,
1154
+ labelFor: options?.labelFor
1155
+ });
1156
+ }
1157
+ };
1158
+ function defineDataset2(definition) {
1159
+ const timeEntries = Object.entries(definition.time);
1160
+ const firstTime = timeEntries[0];
1161
+ if (!firstTime) {
1162
+ throw new Error(`Dataset ${definition.key}: declare at least one time field`);
1163
+ }
1164
+ const time = Object.fromEntries(timeEntries.map(([key, field]) => [
1165
+ key,
1166
+ { column: field.column.node, label: field.label }
1167
+ ]));
1168
+ const dataset = {
1169
+ key: definition.key,
1170
+ label: definition.label,
1171
+ description: definition.description,
1172
+ from: definition.from.ref,
1173
+ source: definition.source,
1174
+ tenantColumn: definition.tenantColumn.node,
1175
+ scope: definition.scope?.node ?? null,
1176
+ time,
1177
+ defaultTime: definition.defaultTime ?? firstTime[0],
1178
+ measures: definition.measures,
1179
+ dimensions: definition.dimensions
1180
+ };
1181
+ return validateDataset(dataset);
1182
+ }
1183
+ function validateDataset(dataset) {
1184
+ const fail = (message) => {
1185
+ throw new Error(`Dataset ${dataset.key}: ${message}`);
1186
+ };
1187
+ const check = (node, where, extraRoots = []) => {
1188
+ visitScoped(node, [dataset.from, ...extraRoots], (inner, roots) => {
1189
+ if (inner.kind === "column") {
1190
+ const root = rootOf(inner.table);
1191
+ if (!roots.includes(root)) {
1192
+ fail(`${where} reads ${inner.table.name}.${inner.column}, which is not joined to ${dataset.from.name}`);
1193
+ }
1194
+ }
1195
+ });
1196
+ };
1197
+ check(dataset.tenantColumn, "tenantColumn");
1198
+ if (dataset.scope)
1199
+ check(dataset.scope, "scope");
1200
+ for (const [key, field] of Object.entries(dataset.time)) {
1201
+ check(field.column, `time field ${key}`);
1202
+ }
1203
+ const measures = dataset.measures;
1204
+ for (const [key, entry] of Object.entries(measures)) {
1205
+ if (entry.kind === "aggregate")
1206
+ check(entry.node, `measure ${key}`);
1207
+ if (entry.kind === "share") {
1208
+ check(entry.numerator, `measure ${key}`);
1209
+ if (entry.denominator)
1210
+ check(entry.denominator, `measure ${key}`);
1211
+ }
1212
+ if (entry.kind === "ratio")
1213
+ checkRatio(measures, key, [], fail);
1214
+ }
1215
+ const dimensions = dataset.dimensions;
1216
+ for (const [key, entry] of Object.entries(dimensions)) {
1217
+ const where = `dimension ${key}`;
1218
+ switch (entry.kind) {
1219
+ case "enum":
1220
+ case "number":
1221
+ check(entry.expr, where);
1222
+ break;
1223
+ case "computed":
1224
+ check(entry.expr, where);
1225
+ for (const value of plainCaseValues(entry.expr)) {
1226
+ if (!(value in entry.values)) {
1227
+ fail(`${where} can produce "${value}", which is missing from its values`);
1228
+ }
1229
+ }
1230
+ break;
1231
+ case "relation":
1232
+ check(entry.key, where);
1233
+ check(entry.name, where);
1234
+ break;
1235
+ case "manyToMany":
1236
+ if (entry.table.join) {
1237
+ fail(`${where}: through.table must be a standalone table(), not a join`);
1238
+ }
1239
+ check(entry.on, where, [entry.table]);
1240
+ check(entry.key, where, [entry.table]);
1241
+ check(entry.name, where, [entry.table]);
1242
+ break;
1243
+ case "timeBucket":
1244
+ case "timePart":
1245
+ if (!(entry.time in dataset.time))
1246
+ fail(`${where} names unknown time field ${entry.time}`);
1247
+ break;
1248
+ }
1249
+ }
1250
+ return dataset;
1251
+ }
1252
+ function checkRatio(measures, key, path, fail) {
1253
+ if (path.includes(key))
1254
+ fail(`ratio measures form a cycle: ${[...path, key].join(" -> ")}`);
1255
+ const entry = measures[key];
1256
+ if (!entry)
1257
+ fail(`ratio measure ${path.at(-1)} names unknown measure ${key}`);
1258
+ if (entry?.kind !== "ratio")
1259
+ return;
1260
+ checkRatio(measures, entry.numerator, [...path, key], fail);
1261
+ checkRatio(measures, entry.denominator, [...path, key], fail);
1262
+ }
1263
+ function visitScoped(node, roots, visit, seenJoins = new Set) {
1264
+ visit(node, roots);
1265
+ if (node.kind === "exists") {
1266
+ visitScoped(node.where, [...roots, node.table], visit, seenJoins);
1267
+ return;
1268
+ }
1269
+ if (node.kind === "column" && node.table.join && !seenJoins.has(node.table)) {
1270
+ seenJoins.add(node.table);
1271
+ visitScoped(node.table.join.on, roots, visit, seenJoins);
1272
+ return;
1273
+ }
1274
+ for (const child of directChildren(node)) {
1275
+ visitScoped(child, roots, visit, seenJoins);
1276
+ }
1277
+ }
1278
+ function plainCaseValues(node) {
1279
+ if (node.kind === "param" && typeof node.value === "string")
1280
+ return [node.value];
1281
+ if (node.kind === "case") {
1282
+ return [
1283
+ ...node.branches.flatMap((branch) => plainCaseValues(branch.result)),
1284
+ ...node.otherwise ? plainCaseValues(node.otherwise) : []
1285
+ ];
1286
+ }
1287
+ if (node.kind === "coalesce")
1288
+ return node.items.flatMap(plainCaseValues);
1289
+ return [];
1290
+ }
1291
+
1292
+ export { MAX_MEASURES, AnalyticsResultSchema2, AnalyticsError2, isAnalyticsError2, createAnalytics2, measure2, dimension2, defineDataset2 };