@pythia-software/query-table-core 0.9.4 → 0.9.6

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/index.js CHANGED
@@ -445,6 +445,101 @@ function renderItemPageTemplate(template, schema, row, id, query) {
445
445
  }
446
446
 
447
447
  // src/metricNormalize.ts
448
+ var METRIC_OPTION_SPECS = {
449
+ top: {
450
+ expression: "string",
451
+ expressionY: "string",
452
+ scope: ["allMatching", "shownRows"],
453
+ groupLimit: "integer",
454
+ minCount: "positiveInteger",
455
+ share: ["total", "group0"]
456
+ },
457
+ window: {
458
+ reduce: ["total", "mean"],
459
+ partial: ["omit", "include"]
460
+ },
461
+ dateGrouping: {
462
+ field: "string",
463
+ interval: ["hour", "day", "week", "month", "year"],
464
+ timeZone: "string",
465
+ includeEmpty: "boolean",
466
+ range: ["query", "observed", "custom"],
467
+ start: "string",
468
+ end: "string",
469
+ partialIntervals: ["include", "exclude"]
470
+ },
471
+ display: {
472
+ kind: [
473
+ "auto",
474
+ "value",
475
+ "table",
476
+ "list",
477
+ "bar-horizontal",
478
+ "bar-vertical",
479
+ "line",
480
+ "pie",
481
+ "donut",
482
+ "scatter",
483
+ "box",
484
+ "histogram"
485
+ ],
486
+ valueLabel: "string",
487
+ xLabel: "string",
488
+ yLabel: "string",
489
+ legendPosition: ["left", "right", "top", "bottom", "none"],
490
+ maxGroups: "positiveInteger",
491
+ showOther: "boolean",
492
+ boxMean: "boolean"
493
+ },
494
+ scale: {
495
+ mode: ["linear", "log"],
496
+ min: "number",
497
+ max: "number"
498
+ },
499
+ format: {
500
+ kind: ["number", "percent", "duration", "date", "time", "datetime"],
501
+ decimals: "integer",
502
+ sourceUnit: [
503
+ "milliseconds",
504
+ "seconds",
505
+ "minutes",
506
+ "hours",
507
+ "days",
508
+ "iso",
509
+ "epochSeconds",
510
+ "epochMilliseconds",
511
+ "secondsOfDay",
512
+ "millisecondsOfDay"
513
+ ],
514
+ style: ["human", "long", "clock", "custom", "iso"],
515
+ pattern: "string",
516
+ timeZone: "string"
517
+ },
518
+ list: {
519
+ showBars: "boolean",
520
+ useGroupColors: "boolean",
521
+ showValues: "boolean",
522
+ barPosition: ["inline", "below"],
523
+ maxLabelLines: "positiveInteger"
524
+ },
525
+ pivot: {
526
+ swap: "boolean",
527
+ rowDir: ["asc", "desc"],
528
+ columnDir: ["asc", "desc"]
529
+ },
530
+ distribution: {
531
+ kind: ["box", "histogram"],
532
+ input: "string",
533
+ whiskers: ["minmax", "tukey"],
534
+ bins: "integer"
535
+ },
536
+ layout: {
537
+ widthRem: "integer",
538
+ heightRem: "integer",
539
+ minWidthRem: "integer",
540
+ minHeightRem: "integer"
541
+ }
542
+ };
448
543
  function normalizeMetricOptions(raw) {
449
544
  const out = {}, diagnostics = [];
450
545
  const record2 = (x) => !!x && typeof x === "object" && !Array.isArray(x);
@@ -461,25 +556,21 @@ function normalizeMetricOptions(raw) {
461
556
  };
462
557
  Object.assign(
463
558
  out,
464
- copy(raw, {
465
- expression: "string",
466
- expressionY: "string",
467
- scope: ["allMatching", "shownRows"],
468
- groupLimit: "integer",
469
- minCount: "positiveInteger"
470
- })
559
+ copy(raw, METRIC_OPTION_SPECS.top)
471
560
  );
561
+ if (raw.window !== void 0) {
562
+ if (record2(raw.window)) {
563
+ const w = copy(raw.window, METRIC_OPTION_SPECS.window);
564
+ const periods = raw.window.periods;
565
+ if (periods === "all" || Number.isSafeInteger(periods) && periods >= 0 && periods <= 1e6)
566
+ w.periods = periods;
567
+ else diagnostics.push("Invalid metric window.");
568
+ out.window = w;
569
+ } else diagnostics.push("Invalid metric window.");
570
+ }
472
571
  if (raw.dateGrouping !== void 0) {
473
572
  if (record2(raw.dateGrouping)) {
474
- const d = copy(raw.dateGrouping, {
475
- field: "string",
476
- interval: ["hour", "day", "week", "month", "year"],
477
- timeZone: "string",
478
- includeEmpty: "boolean",
479
- range: ["query", "observed", "custom"],
480
- start: "string",
481
- end: "string"
482
- });
573
+ const d = copy(raw.dateGrouping, METRIC_OPTION_SPECS.dateGrouping);
483
574
  if (typeof d.field !== "string" || !d.field || d.field.length > 256 || !d.interval || typeof d.includeEmpty !== "boolean" || !d.range)
484
575
  diagnostics.push("Invalid metric date grouping.");
485
576
  out.dateGrouping = d;
@@ -489,94 +580,30 @@ function normalizeMetricOptions(raw) {
489
580
  if (record2(raw.display)) {
490
581
  if (typeof raw.display.kind !== "string")
491
582
  diagnostics.push("Invalid metric display kind.");
492
- const d = copy(raw.display, {
493
- kind: [
494
- "auto",
495
- "value",
496
- "table",
497
- "list",
498
- "bar-horizontal",
499
- "bar-vertical",
500
- "line",
501
- "pie",
502
- "donut",
503
- "scatter",
504
- "box",
505
- "histogram"
506
- ],
507
- valueLabel: "string",
508
- xLabel: "string",
509
- yLabel: "string",
510
- legendPosition: ["left", "right", "top", "bottom", "none"],
511
- maxGroups: "positiveInteger",
512
- showOther: "boolean",
513
- boxMean: "boolean"
514
- });
583
+ const d = copy(raw.display, METRIC_OPTION_SPECS.display);
515
584
  for (const k of ["xScale", "yScale"]) {
516
585
  if (record2(raw.display[k]))
517
- d[k] = copy(raw.display[k], {
518
- mode: ["linear", "log"],
519
- min: "number",
520
- max: "number"
521
- });
586
+ d[k] = copy(raw.display[k], METRIC_OPTION_SPECS.scale);
522
587
  else if (raw.display[k] !== void 0)
523
588
  diagnostics.push(`Invalid metric ${k}.`);
524
589
  }
525
590
  for (const k of ["format", "xFormat", "yFormat"])
526
591
  if (record2(raw.display[k]))
527
- d[k] = copy(raw.display[k], {
528
- kind: ["number", "percent", "duration", "date", "time", "datetime"],
529
- decimals: "integer",
530
- sourceUnit: [
531
- "milliseconds",
532
- "seconds",
533
- "minutes",
534
- "hours",
535
- "days",
536
- "iso",
537
- "epochSeconds",
538
- "epochMilliseconds",
539
- "secondsOfDay",
540
- "millisecondsOfDay"
541
- ],
542
- style: ["human", "long", "clock", "custom", "iso"],
543
- pattern: "string",
544
- timeZone: "string"
545
- });
592
+ d[k] = copy(raw.display[k], METRIC_OPTION_SPECS.format);
546
593
  if (record2(raw.display.list))
547
- d.list = copy(raw.display.list, {
548
- showBars: "boolean",
549
- useGroupColors: "boolean",
550
- showValues: "boolean",
551
- barPosition: ["inline", "below"],
552
- maxLabelLines: "positiveInteger"
553
- });
594
+ d.list = copy(raw.display.list, METRIC_OPTION_SPECS.list);
554
595
  if (record2(raw.display.pivot))
555
- d.pivot = copy(raw.display.pivot, {
556
- swap: "boolean",
557
- rowDir: ["asc", "desc"],
558
- columnDir: ["asc", "desc"]
559
- });
596
+ d.pivot = copy(raw.display.pivot, METRIC_OPTION_SPECS.pivot);
560
597
  out.display = { kind: "auto", ...d };
561
598
  } else diagnostics.push("Invalid metric display.");
562
599
  }
563
600
  if (raw.distribution !== void 0) {
564
601
  if (record2(raw.distribution))
565
- out.distribution = copy(raw.distribution, {
566
- kind: ["box", "histogram"],
567
- input: "string",
568
- whiskers: ["minmax", "tukey"],
569
- bins: "integer"
570
- });
602
+ out.distribution = copy(raw.distribution, METRIC_OPTION_SPECS.distribution);
571
603
  else diagnostics.push("Invalid metric distribution.");
572
604
  }
573
605
  if (record2(raw.layout)) {
574
- const l = copy(raw.layout, {
575
- widthRem: "integer",
576
- heightRem: "integer",
577
- minWidthRem: "integer",
578
- minHeightRem: "integer"
579
- });
606
+ const l = copy(raw.layout, METRIC_OPTION_SPECS.layout);
580
607
  for (const k of ["widthRem", "heightRem", "minWidthRem", "minHeightRem"])
581
608
  l[k] = Math.max(1, Math.min(1e3, Number(l[k] ?? 1)));
582
609
  l.widthRem = Math.max(Number(l.widthRem), Number(l.minWidthRem));
@@ -684,7 +711,7 @@ function cloneWhereTerm(term) {
684
711
  function normalizeSetFilter(item) {
685
712
  if (!isRecord(item)) return void 0;
686
713
  const id = boundedString(item.id, MAX_FIELD_NAME_LENGTH);
687
- if (!id || !["any", "all", "none", "empty"].includes(String(item.mode)) || !Array.isArray(item.values)) return void 0;
714
+ if (!id || typeof item.mode !== "string" || !["any", "all", "none", "empty"].includes(item.mode) || !Array.isArray(item.values)) return void 0;
688
715
  if (item.values.length > MAX_WHERE_CLAUSES || !item.values.every((value) => typeof value === "string" && value.length <= MAX_FILTER_VALUE_LENGTH)) return void 0;
689
716
  return { id, mode: item.mode, values: [...item.values] };
690
717
  }
@@ -1251,7 +1278,7 @@ function toBase64Url(json) {
1251
1278
  }
1252
1279
  function fromBase64Url(s) {
1253
1280
  const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
1254
- if (typeof Buffer !== "undefined") return Buffer.from(b64, "base64").toString("utf8");
1281
+ if (typeof Buffer !== "undefined") return Buffer.from(b64, "base64").toString("utf8").replace(/^\uFEFF/, "");
1255
1282
  const bin = atob(b64);
1256
1283
  const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
1257
1284
  return new TextDecoder().decode(bytes);
@@ -3066,32 +3093,661 @@ function formulaRuntime(ast, inputs, extension) {
3066
3093
  out = Math.trunc((date(c).getTime() - date(b).getTime()) / ms);
3067
3094
  break;
3068
3095
  }
3069
- case "FORMAT_DATE": {
3070
- const d = date(a), pad = (n2) => String(n2).padStart(2, "0"), parts2 = {
3071
- YYYY: String(d.getUTCFullYear()).padStart(4, "0"),
3072
- MM: pad(d.getUTCMonth() + 1),
3073
- DD: pad(d.getUTCDate()),
3074
- HH: pad(d.getUTCHours()),
3075
- mm: pad(d.getUTCMinutes()),
3076
- ss: pad(d.getUTCSeconds())
3077
- };
3078
- out = str(b).replace(/YYYY|MM|DD|HH|mm|ss/g, (t) => parts2[t]);
3079
- break;
3096
+ case "FORMAT_DATE": {
3097
+ const d = date(a), pad = (n2) => String(n2).padStart(2, "0"), parts2 = {
3098
+ YYYY: String(d.getUTCFullYear()).padStart(4, "0"),
3099
+ MM: pad(d.getUTCMonth() + 1),
3100
+ DD: pad(d.getUTCDate()),
3101
+ HH: pad(d.getUTCHours()),
3102
+ mm: pad(d.getUTCMinutes()),
3103
+ ss: pad(d.getUTCSeconds())
3104
+ };
3105
+ out = str(b).replace(/YYYY|MM|DD|HH|mm|ss/g, (t) => parts2[t]);
3106
+ break;
3107
+ }
3108
+ default:
3109
+ return fail(`Unknown operation: ${name}`);
3110
+ }
3111
+ return bound(out);
3112
+ }
3113
+ try {
3114
+ const value = run(ast);
3115
+ return inspection ? { value, regex: inspection } : { value };
3116
+ } catch (e) {
3117
+ return {
3118
+ value: null,
3119
+ error: e instanceof Error ? e.message : "Formula evaluation failed."
3120
+ };
3121
+ }
3122
+ }
3123
+
3124
+ // src/metricDates.ts
3125
+ import { Temporal } from "temporal-polyfill";
3126
+ var METRIC_DATE_INTERVALS = [
3127
+ "hour",
3128
+ "day",
3129
+ "week",
3130
+ "month",
3131
+ "year"
3132
+ ];
3133
+ function metricDateTime(value, timeZone = "UTC") {
3134
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2}))?$/.test(
3135
+ value
3136
+ ))
3137
+ throw Error(
3138
+ "Date grouping requires ISO dates or timestamps with a timezone."
3139
+ );
3140
+ const time = Date.parse(value), month = Number(value.slice(5, 7)), day = Number(value.slice(8, 10));
3141
+ const calendar = /* @__PURE__ */ new Date(0);
3142
+ calendar.setUTCFullYear(Number(value.slice(0, 4)), month, 0);
3143
+ if (!Number.isFinite(time) || month < 1 || month > 12 || day < 1 || day > calendar.getUTCDate())
3144
+ throw Error("Invalid date grouping value.");
3145
+ return value.length === 10 && timeZone !== "UTC" ? Temporal.PlainDate.from(value).toZonedDateTime(timeZone).epochMilliseconds : time;
3146
+ }
3147
+ function metricOffsetTransition(start, end, timeZone) {
3148
+ if (end <= start) return void 0;
3149
+ const offset = (time) => Temporal.Instant.fromEpochMilliseconds(time).toZonedDateTimeISO(timeZone).offsetNanoseconds;
3150
+ const before = offset(start);
3151
+ if (before === offset(end)) return void 0;
3152
+ while (end - start > 1) {
3153
+ const middle = Math.floor((start + end) / 2);
3154
+ if (offset(middle) === before) start = middle;
3155
+ else end = middle;
3156
+ }
3157
+ return end;
3158
+ }
3159
+ function metricDateFloor(time, interval, timeZone = "UTC") {
3160
+ if (timeZone !== "UTC") {
3161
+ const zoned = Temporal.Instant.fromEpochMilliseconds(time).toZonedDateTimeISO(timeZone);
3162
+ if (interval === "hour") {
3163
+ const floor = time - zoned.minute * 6e4 - zoned.second * 1e3 - zoned.millisecond;
3164
+ return metricOffsetTransition(floor, time, timeZone) ?? floor;
3165
+ }
3166
+ let date2 = zoned.toPlainDate();
3167
+ if (interval === "week") date2 = date2.subtract({ days: date2.dayOfWeek - 1 });
3168
+ if (interval === "month" || interval === "year")
3169
+ date2 = date2.with({ day: 1 });
3170
+ if (interval === "year") date2 = date2.with({ month: 1 });
3171
+ return date2.toZonedDateTime(timeZone).epochMilliseconds;
3172
+ }
3173
+ const date = new Date(time);
3174
+ date.setUTCMinutes(0, 0, 0);
3175
+ if (interval !== "hour") date.setUTCHours(0);
3176
+ if (interval === "week")
3177
+ date.setUTCDate(date.getUTCDate() - (date.getUTCDay() + 6) % 7);
3178
+ if (interval === "month" || interval === "year") date.setUTCDate(1);
3179
+ if (interval === "year") date.setUTCMonth(0);
3180
+ return date.getTime();
3181
+ }
3182
+ function metricDateNext(time, interval, timeZone = "UTC") {
3183
+ if (timeZone !== "UTC") {
3184
+ const zoned = Temporal.Instant.fromEpochMilliseconds(time).toZonedDateTimeISO(timeZone);
3185
+ if (interval === "hour") {
3186
+ const target = zoned.toPlainDateTime().with({
3187
+ minute: 0,
3188
+ second: 0,
3189
+ millisecond: 0,
3190
+ microsecond: 0,
3191
+ nanosecond: 0
3192
+ }).add({ hours: 1 });
3193
+ let next = target.toZonedDateTime(timeZone).epochMilliseconds;
3194
+ if (next <= time)
3195
+ next = target.toZonedDateTime(timeZone, {
3196
+ disambiguation: "later"
3197
+ }).epochMilliseconds;
3198
+ return metricOffsetTransition(time, next, timeZone) ?? next;
3199
+ }
3200
+ let date2 = zoned.toPlainDate();
3201
+ if (interval === "month") date2 = date2.with({ day: 1 }).add({ months: 1 });
3202
+ else if (interval === "year")
3203
+ date2 = date2.with({ month: 1, day: 1 }).add({ years: 1 });
3204
+ else if (interval === "week")
3205
+ date2 = date2.subtract({ days: date2.dayOfWeek - 1 }).add({ weeks: 1 });
3206
+ else date2 = date2.add({ days: 1 });
3207
+ return date2.toZonedDateTime(timeZone).epochMilliseconds;
3208
+ }
3209
+ const date = new Date(time);
3210
+ if (interval === "hour") date.setUTCHours(date.getUTCHours() + 1);
3211
+ else if (interval === "month") date.setUTCMonth(date.getUTCMonth() + 1);
3212
+ else if (interval === "year") date.setUTCFullYear(date.getUTCFullYear() + 1);
3213
+ else date.setUTCDate(date.getUTCDate() + (interval === "week" ? 7 : 1));
3214
+ return date.getTime();
3215
+ }
3216
+ function metricCustomEndTime(value, timeZone = "UTC") {
3217
+ const time = metricDateTime(value, timeZone);
3218
+ return value.length === 10 ? metricDateNext(time, "day", timeZone) - 1 : time;
3219
+ }
3220
+ function validateDateGrouping(config) {
3221
+ if (typeof config.field !== "string" || !config.field || config.field.length > 256 || !METRIC_DATE_INTERVALS.includes(config.interval) || typeof config.includeEmpty !== "boolean" || !["query", "observed", "custom"].includes(config.range) || config.partialIntervals !== void 0 && !["include", "exclude"].includes(config.partialIntervals))
3222
+ throw Error("Invalid date grouping definition.");
3223
+ if (config.timeZone !== void 0) {
3224
+ if (typeof config.timeZone !== "string" || !config.timeZone || config.timeZone.length > 80 || /^[+-]/.test(config.timeZone))
3225
+ throw Error(
3226
+ "Use a valid IANA grouping timezone, such as America/Denver or UTC."
3227
+ );
3228
+ try {
3229
+ new Intl.DateTimeFormat("en-US", { timeZone: config.timeZone });
3230
+ } catch {
3231
+ throw Error(
3232
+ "Use a valid IANA grouping timezone, such as America/Denver or UTC."
3233
+ );
3234
+ }
3235
+ }
3236
+ if (config.includeEmpty && config.range === "custom") {
3237
+ const start = metricDateTime(config.start ?? "", config.timeZone), end = metricCustomEndTime(config.end ?? "", config.timeZone);
3238
+ if (end < start)
3239
+ throw Error("The end date must be on or after the start date.");
3240
+ }
3241
+ }
3242
+ function metricDateRange(config, query, observed, now, canonical) {
3243
+ let min = Infinity, max = -Infinity;
3244
+ for (const time of observed) {
3245
+ min = Math.min(min, time);
3246
+ max = Math.max(max, time);
3247
+ }
3248
+ if (config.range === "observed")
3249
+ return observed.length ? [min, max] : void 0;
3250
+ if (config.range === "custom") {
3251
+ const start2 = metricDateFloor(
3252
+ metricDateTime(config.start ?? "", config.timeZone),
3253
+ config.interval,
3254
+ config.timeZone
3255
+ );
3256
+ const end2 = metricDateFloor(
3257
+ metricCustomEndTime(config.end ?? "", config.timeZone),
3258
+ config.interval,
3259
+ config.timeZone
3260
+ );
3261
+ if (observed.length && (start2 > min || end2 < max))
3262
+ throw Error(
3263
+ "Custom date bounds must include all observed buckets. Narrow the query filters to exclude data."
3264
+ );
3265
+ return [start2, end2];
3266
+ }
3267
+ let start = -Infinity, end = Infinity;
3268
+ for (const term of query.where) {
3269
+ if (isOrGroup(term) || term.negated || canonical(term.field) !== canonical(config.field))
3270
+ continue;
3271
+ if (!["=", ">", ">=", "<", "<="].includes(term.op)) continue;
3272
+ const time = Number(coerceValue("datetime", term.value, now));
3273
+ if (term.op === ">" || term.op === ">=" || term.op === "=")
3274
+ start = Math.max(start, time + (term.op === ">" ? 1 : 0));
3275
+ if (term.op === "<" || term.op === "<=" || term.op === "=")
3276
+ end = Math.min(end, time - (term.op === "<" ? 1 : 0));
3277
+ }
3278
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
3279
+ const missing = [
3280
+ ...Number.isFinite(start) ? [] : ["a lower bound (>= or >)"],
3281
+ ...Number.isFinite(end) ? [] : ["an upper bound (<= or <)"]
3282
+ ].join(" and ");
3283
+ throw Error(
3284
+ `Include empty dates with range "query" needs a bounded date filter on ${config.field}: add ${missing} as top-level filters (bounds inside OR groups do not count). Otherwise use range "observed" to fill from the first to the last observed date, or range "custom" with start and end.`
3285
+ );
3286
+ }
3287
+ if (end < start) return void 0;
3288
+ return [
3289
+ metricDateFloor(start, config.interval, config.timeZone),
3290
+ metricDateFloor(end, config.interval, config.timeZone)
3291
+ ];
3292
+ }
3293
+
3294
+ // src/metricPrecision.ts
3295
+ function assertMetricNumber(value) {
3296
+ if (!Number.isFinite(value) || Number.isInteger(value) && !Number.isSafeInteger(value)) {
3297
+ throw Error("Aggregate exceeds finite safe numeric precision.");
3298
+ }
3299
+ }
3300
+ function checkedMetricResult(result) {
3301
+ if (result.error || typeof result.value !== "number") return result;
3302
+ try {
3303
+ assertMetricNumber(result.value);
3304
+ return result;
3305
+ } catch (e) {
3306
+ return { value: null, error: e.message };
3307
+ }
3308
+ }
3309
+
3310
+ // src/metricSeries.ts
3311
+ var MAX_METRIC_WINDOW_PERIODS = 1e3;
3312
+ var MERGEABLE = ["SUM", "COUNT", "MIN", "MAX", "AVG"];
3313
+ function hasMetricSeries(c) {
3314
+ return c.window !== void 0 || c.share !== void 0 || c.dateGrouping?.partialIntervals !== void 0;
3315
+ }
3316
+ var soleAggregate = (plan) => plan.ast.kind === "call" && plan.aggregates.includes(plan.ast) ? plan.ast.name : void 0;
3317
+ var partBudget = (c, plan) => (c.window && (c.window.reduce ?? "total") === "total" ? plan.aggregates.reduce((n, node) => n + (node.name === "AVG" ? 2 : 1), 0) : 1) * (c.share ? 2 : 1);
3318
+ function validateMetricSeries(c, plan) {
3319
+ const { window, share } = c;
3320
+ if (!window && share === void 0) return;
3321
+ if (!("aggregates" in plan) || c.display?.kind === "scatter")
3322
+ throw new FormulaError(
3323
+ "Windows and shares apply to single-value metrics, not distributions or scatters."
3324
+ );
3325
+ const metric = plan, sole = soleAggregate(metric);
3326
+ if (window) {
3327
+ if (!c.dateGrouping)
3328
+ throw new FormulaError(
3329
+ "A window needs a calendar interval on the first grouping field."
3330
+ );
3331
+ if (!c.dateGrouping.includeEmpty)
3332
+ throw new FormulaError(
3333
+ "A window spans every interval, including empty ones: include empty dates."
3334
+ );
3335
+ const { periods, reduce = "total", partial = "omit" } = window;
3336
+ if (periods !== "all" && !(Number.isInteger(periods) && periods >= 2 && periods <= MAX_METRIC_WINDOW_PERIODS))
3337
+ throw new FormulaError(
3338
+ "A window covers 2\u20131,000 intervals, or all of them."
3339
+ );
3340
+ if (!["total", "mean"].includes(reduce) || !["omit", "include"].includes(partial))
3341
+ throw new FormulaError("Invalid metric window.");
3342
+ if (reduce === "mean") {
3343
+ if (!sole || !["SUM", "COUNT", "COUNT_DISTINCT"].includes(sole))
3344
+ throw new FormulaError(
3345
+ "A mean per interval needs a single SUM, COUNT or COUNT_DISTINCT; it would weight each interval's ratio or average equally. A window total pools them instead."
3346
+ );
3347
+ } else
3348
+ for (const node of metric.aggregates)
3349
+ if (!MERGEABLE.includes(node.name))
3350
+ throw new FormulaError(
3351
+ node.name === "COUNT_DISTINCT" ? "A window total cannot combine COUNT_DISTINCT across intervals, because one value can occur in several. Use a mean per interval." : `A window total cannot combine ${node.name} across intervals.`,
3352
+ node.from,
3353
+ node.to
3354
+ );
3355
+ }
3356
+ if (share !== void 0) {
3357
+ if (share !== "total" && share !== "group0")
3358
+ throw new FormulaError("Invalid metric share.");
3359
+ if (sole !== "SUM" && sole !== "COUNT")
3360
+ throw new FormulaError(
3361
+ "A share divides additive totals: use a single SUM or COUNT. Distinct counts, averages and ratios do not add up across groups."
3362
+ );
3363
+ if (share === "group0" ? c.groupBy.length < 2 : !c.groupBy.length)
3364
+ throw new FormulaError(
3365
+ share === "group0" ? "A share within the first grouping needs a second grouping field." : "A share of the total needs a grouping field."
3366
+ );
3367
+ if (share === "total" && window)
3368
+ throw new FormulaError(
3369
+ "A windowed share divides by each interval's own total: share within the first grouping."
3370
+ );
3371
+ }
3372
+ if (partBudget(c, metric) > MAX_AGGREGATIONS)
3373
+ throw new FormulaError(
3374
+ `A window or share runs one metric per aggregate; this one needs more than ${MAX_AGGREGATIONS}.`
3375
+ );
3376
+ }
3377
+ function wholeIntervalSpan(c, query, schema, now) {
3378
+ const { field, interval, timeZone } = c.dateGrouping;
3379
+ const canonical = (name) => resolveFieldName(schema, name) ?? name;
3380
+ let lower = -Infinity, upper = Infinity;
3381
+ for (const term of [...query.where, ...c.where ?? []]) {
3382
+ if (isOrGroup(term) || term.negated || canonical(term.field) !== canonical(field) || !["=", ">", ">=", "<", "<="].includes(term.op))
3383
+ continue;
3384
+ if (term.value === "") continue;
3385
+ let time;
3386
+ try {
3387
+ time = Number(coerceValue("datetime", term.value, now));
3388
+ } catch {
3389
+ continue;
3390
+ }
3391
+ if (!Number.isFinite(time)) continue;
3392
+ if (term.op === ">" || term.op === ">=" || term.op === "=")
3393
+ lower = Math.max(lower, time + (term.op === ">" ? 1 : 0));
3394
+ if (term.op === "<" || term.op === "<=" || term.op === "=")
3395
+ upper = Math.min(upper, time + (term.op === "<" ? 0 : 1));
3396
+ }
3397
+ const floor = (time) => metricDateFloor(time, interval, timeZone);
3398
+ const end = floor(Math.min(upper, now));
3399
+ if (!Number.isFinite(lower)) return { end };
3400
+ const first = floor(lower);
3401
+ return {
3402
+ start: first === lower ? lower : metricDateNext(first, interval, timeZone),
3403
+ end
3404
+ };
3405
+ }
3406
+ function planMetricSeries(query, schema, options2 = {}) {
3407
+ const clauses = query.aggregations ?? [];
3408
+ if (!clauses.some(hasMetricSeries)) return void 0;
3409
+ const now = options2.now ?? Date.now();
3410
+ const taken = new Set(clauses.map((c) => c.id));
3411
+ let next = 0;
3412
+ const id = () => {
3413
+ let value;
3414
+ do
3415
+ value = `qt-series-${next++}`;
3416
+ while (taken.has(value));
3417
+ return value;
3418
+ };
3419
+ const prepared = clauses.map((c) => {
3420
+ if (!hasMetricSeries(c)) return { kind: "plain", clause: c, parts: [c] };
3421
+ try {
3422
+ return prepare(c);
3423
+ } catch (e) {
3424
+ return {
3425
+ kind: "error",
3426
+ clause: c,
3427
+ error: e instanceof Error ? e.message : String(e)
3428
+ };
3429
+ }
3430
+ });
3431
+ function prepare(c) {
3432
+ const issue = validateMetric(c, schema, options2.resolveComputed)[0];
3433
+ if (issue) return { kind: "error", clause: c, error: issue.message };
3434
+ let where = c.where ?? [];
3435
+ let dateGrouping;
3436
+ if (c.dateGrouping) {
3437
+ const { partialIntervals, ...config } = c.dateGrouping;
3438
+ dateGrouping = config;
3439
+ if (partialIntervals === "exclude") {
3440
+ const span = wholeIntervalSpan(c, query, schema, now);
3441
+ if (span.start !== void 0 && span.end <= span.start)
3442
+ return { kind: "empty", clause: c };
3443
+ const field = resolveFieldName(schema, config.field) ?? config.field;
3444
+ const iso = (time) => new Date(time).toISOString();
3445
+ where = [
3446
+ ...where,
3447
+ ...span.start !== void 0 ? [{ field, op: ">=", value: iso(span.start) }] : [],
3448
+ { field, op: "<", value: iso(span.end) }
3449
+ ];
3450
+ if (config.includeEmpty && config.range !== "observed") {
3451
+ const floor = (time) => metricDateFloor(time, config.interval, config.timeZone);
3452
+ let from = span.start, through = span.end - 1;
3453
+ if (config.range === "custom") {
3454
+ const start = floor(
3455
+ metricDateTime(config.start ?? "", config.timeZone)
3456
+ );
3457
+ from = Math.max(from ?? start, start);
3458
+ through = Math.min(
3459
+ through,
3460
+ metricCustomEndTime(config.end ?? "", config.timeZone)
3461
+ );
3462
+ }
3463
+ if (from !== void 0) {
3464
+ if (through < from) return { kind: "empty", clause: c };
3465
+ dateGrouping = {
3466
+ ...config,
3467
+ range: "custom",
3468
+ start: iso(from),
3469
+ end: iso(through)
3470
+ };
3471
+ }
3472
+ }
3473
+ }
3474
+ }
3475
+ const scoped = (groupBy) => ({
3476
+ ...c.scope ? { scope: c.scope } : {},
3477
+ ...where.length ? { where } : {},
3478
+ ...dateGrouping && groupBy.length ? { dateGrouping } : {}
3479
+ });
3480
+ if (!c.window && c.share === void 0) {
3481
+ const {
3482
+ dateGrouping: _date,
3483
+ where: _where,
3484
+ scope: _scope,
3485
+ ...rest
3486
+ } = c;
3487
+ return {
3488
+ kind: "plain",
3489
+ clause: c,
3490
+ parts: [{ ...rest, id: id(), ...scoped(c.groupBy) }]
3491
+ };
3492
+ }
3493
+ const source = metricExpression(
3494
+ c.expression === void 0 && c.field ? { ...c, field: resolveFieldName(schema, c.field) ?? c.field } : c
3495
+ );
3496
+ const plan = compileMetric(source, schema, options2.resolveComputed);
3497
+ const texts = [];
3498
+ const at = (text) => {
3499
+ const index = texts.indexOf(text);
3500
+ return index < 0 ? texts.push(text) - 1 : index;
3501
+ };
3502
+ let reductions;
3503
+ if (c.window && (c.window.reduce ?? "total") === "total")
3504
+ reductions = plan.aggregates.map((node) => {
3505
+ const text = source.slice(node.from, node.to);
3506
+ if (node.name !== "AVG") return { name: node.name, part: at(text) };
3507
+ const input = text.slice(text.indexOf("(") + 1, text.lastIndexOf(")"));
3508
+ return { name: "AVG", sum: at(`SUM(${input})`), count: at(`COUNT(${input})`) };
3509
+ });
3510
+ else at(source);
3511
+ const parts = (groupBy) => texts.map(
3512
+ (text) => ({
3513
+ id: id(),
3514
+ // The whole legacy measure keeps its operation, so v1 servers run it.
3515
+ ...text === source && c.expression === void 0 ? { op: c.op, ...c.field ? { field: c.field } : {} } : { op: "count", expression: text },
3516
+ groupBy,
3517
+ ...scoped(groupBy)
3518
+ })
3519
+ );
3520
+ return {
3521
+ kind: "series",
3522
+ clause: c,
3523
+ plan,
3524
+ parts: [
3525
+ ...parts(c.groupBy),
3526
+ ...c.share === "group0" ? parts(c.groupBy.slice(0, 1)) : c.share === "total" ? parts([]) : []
3527
+ ],
3528
+ ...reductions ? { reductions } : {},
3529
+ size: texts.length
3530
+ };
3531
+ }
3532
+ const queries = [];
3533
+ let batch = [];
3534
+ for (const item of prepared) {
3535
+ if (item.kind === "error" || item.kind === "empty") continue;
3536
+ if (batch.length + item.parts.length > MAX_AGGREGATIONS) {
3537
+ queries.push({ ...query, aggregations: batch });
3538
+ batch = [];
3539
+ }
3540
+ batch.push(...item.parts);
3541
+ }
3542
+ if (batch.length) queries.push({ ...query, aggregations: batch });
3543
+ return {
3544
+ queries,
3545
+ combine(results) {
3546
+ const byId = new Map(results.map((r) => [r.id, r]));
3547
+ return prepared.map((item) => {
3548
+ const c = item.clause;
3549
+ if (item.kind === "error")
3550
+ return { id: c.id, buckets: [], error: item.error };
3551
+ if (item.kind === "empty")
3552
+ return {
3553
+ id: c.id,
3554
+ buckets: [],
3555
+ scope: c.scope ?? "allMatching",
3556
+ groupCount: 0,
3557
+ coverage: "exact"
3558
+ };
3559
+ const entries = item.parts.map(
3560
+ (part) => byId.get(part.id) ?? {
3561
+ id: part.id,
3562
+ buckets: [],
3563
+ error: "Server omitted this metric."
3564
+ }
3565
+ );
3566
+ if (item.kind === "plain") return { ...entries[0], id: c.id };
3567
+ try {
3568
+ return combineSeries(item, entries);
3569
+ } catch (e) {
3570
+ return {
3571
+ id: c.id,
3572
+ buckets: [],
3573
+ error: e instanceof Error ? e.message : String(e)
3574
+ };
3575
+ }
3576
+ });
3577
+ }
3578
+ };
3579
+ }
3580
+ var Accumulator = class {
3581
+ constructor(name) {
3582
+ this.name = name;
3583
+ this.total = 0;
3584
+ this.seen = false;
3585
+ this.best = null;
3586
+ }
3587
+ add(bucket) {
3588
+ if (!bucket || this.error) return;
3589
+ if (bucket.error) {
3590
+ this.error = bucket.error;
3591
+ return;
3592
+ }
3593
+ const value = bucket.value;
3594
+ if (value === null) return;
3595
+ if (this.name === "SUM" || this.name === "COUNT") {
3596
+ if (typeof value !== "number") {
3597
+ this.error = `${this.name} returned a non-numeric value.`;
3598
+ return;
3599
+ }
3600
+ this.total += value;
3601
+ this.seen = true;
3602
+ } else if (this.best === null || compareMetricValues(value, this.best) * (this.name === "MIN" ? 1 : -1) < 0)
3603
+ this.best = value;
3604
+ }
3605
+ result() {
3606
+ if (this.error) return { value: null, error: this.error };
3607
+ if (this.name === "MIN" || this.name === "MAX") return { value: this.best };
3608
+ return { value: this.name === "COUNT" || this.seen ? this.total : null };
3609
+ }
3610
+ };
3611
+ function combineSeries(item, entries) {
3612
+ const c = item.clause, window = c.window;
3613
+ for (const entry of entries) {
3614
+ if (entry.error) throw Error(entry.error);
3615
+ if (entry.coverage === "partial" || (entry.groupCount ?? entry.buckets.length) > entry.buckets.length)
3616
+ throw Error(
3617
+ "A window or share needs every group, but the engine returned only some of them."
3618
+ );
3619
+ }
3620
+ const numerator = entries.slice(0, item.size), denominator = c.share ? entries.slice(item.size) : [];
3621
+ const dateOf = (keys) => keys[0] === null || keys[0] === void 0 ? void 0 : keys[0];
3622
+ const times = /* @__PURE__ */ new Map();
3623
+ if (window)
3624
+ for (const entry of entries)
3625
+ for (const bucket of entry.buckets) {
3626
+ const date = dateOf(bucket.keys);
3627
+ if (date !== void 0 && !times.has(date))
3628
+ times.set(date, metricDateTime(date));
3080
3629
  }
3081
- default:
3082
- return fail(`Unknown operation: ${name}`);
3630
+ const order = [...times.entries()].sort((a, b) => a[1] - b[1]);
3631
+ const position = new Map(order.map(([date], index) => [date, index]));
3632
+ const positionOf = (keys) => {
3633
+ const date = dateOf(keys);
3634
+ return date === void 0 ? void 0 : position.get(date);
3635
+ };
3636
+ const tracks = (part) => {
3637
+ const byTail = /* @__PURE__ */ new Map();
3638
+ for (const bucket of part.buckets) {
3639
+ const at = positionOf(bucket.keys);
3640
+ if (at === void 0) continue;
3641
+ const tail = JSON.stringify(bucket.keys.slice(1));
3642
+ const track = byTail.get(tail) ?? Array(order.length);
3643
+ track[at] = bucket;
3644
+ byTail.set(tail, track);
3645
+ }
3646
+ return byTail;
3647
+ };
3648
+ const exact = (part) => new Map(
3649
+ part.buckets.map((bucket) => [JSON.stringify(bucket.keys), bucket])
3650
+ );
3651
+ const numeratorTracks = numerator.map(tracks), denominatorTracks = denominator.map(tracks);
3652
+ const numeratorBuckets = numerator.map(exact), denominatorBuckets = denominator.map(exact);
3653
+ const checked = (result, count) => {
3654
+ if (!result.error && typeof result.value === "number")
3655
+ try {
3656
+ assertMetricNumber(result.value);
3657
+ } catch (e) {
3658
+ result = { value: null, error: e.message };
3659
+ }
3660
+ return result.error ? { value: null, count, error: result.error } : { value: result.value, count };
3661
+ };
3662
+ const evaluate = (members, mean) => {
3663
+ const own = members[0], count = own.reduce((n, bucket) => n + (bucket?.count ?? 0), 0);
3664
+ if (item.reductions) {
3665
+ const reduce = (name, part) => {
3666
+ const accumulator = new Accumulator(name);
3667
+ for (const bucket of members[part]) accumulator.add(bucket);
3668
+ return accumulator.result();
3669
+ };
3670
+ const reductions = item.reductions.map((r) => {
3671
+ if (r.name !== "AVG") return reduce(r.name, r.part);
3672
+ const sum = reduce("SUM", r.sum), rows = reduce("COUNT", r.count);
3673
+ if (sum.error || rows.error)
3674
+ return { value: null, error: sum.error ?? rows.error };
3675
+ return {
3676
+ value: sum.value === null || !rows.value ? null : Number(sum.value) / Number(rows.value)
3677
+ };
3678
+ });
3679
+ return checked(evaluateMetricReductions(item.plan, reductions), count);
3680
+ }
3681
+ if (!mean)
3682
+ return own[0]?.error ? { value: null, count, error: own[0].error } : checked({ value: own[0]?.value ?? null }, count);
3683
+ let total = 0;
3684
+ for (const bucket of own) {
3685
+ if (bucket?.error) return { value: null, count, error: bucket.error };
3686
+ if (bucket?.value === null || bucket?.value === void 0) continue;
3687
+ if (typeof bucket.value !== "number")
3688
+ return { value: null, count, error: "Expected a numeric metric value." };
3689
+ total += bucket.value;
3690
+ }
3691
+ return checked({ value: total / own.length }, count);
3692
+ };
3693
+ const periods = window?.periods === "all" ? Infinity : window?.periods ?? 1;
3694
+ const point = (keys, tracksOf, bucketsOf) => {
3695
+ const at = positionOf(keys);
3696
+ if (!window || at === void 0)
3697
+ return evaluate(
3698
+ bucketsOf.map((parts) => [parts.get(JSON.stringify(keys))]),
3699
+ false
3700
+ );
3701
+ const from = Math.max(0, at - periods + 1), intervals = at - from + 1, tail = JSON.stringify(keys.slice(1));
3702
+ const members = tracksOf.map((byTail) => {
3703
+ const track = byTail.get(tail);
3704
+ return Array.from({ length: intervals }, (_, i) => track?.[from + i]);
3705
+ });
3706
+ const result = evaluate(members, window.reduce === "mean");
3707
+ return intervals < periods && periods !== Infinity && (window.partial ?? "omit") === "omit" ? { value: null, count: result.count, intervals } : { ...result, intervals };
3708
+ };
3709
+ const keyed = /* @__PURE__ */ new Map();
3710
+ for (const entry of numerator)
3711
+ for (const bucket of entry.buckets)
3712
+ keyed.set(JSON.stringify(bucket.keys), bucket.keys);
3713
+ let buckets = [...keyed.values()].map((keys) => {
3714
+ const own = point(keys, numeratorTracks, numeratorBuckets);
3715
+ let { value } = own, error = own.error;
3716
+ if (c.share && !error) {
3717
+ const total = c.share === "group0" ? keys.slice(0, 1) : [];
3718
+ const whole = point(total, denominatorTracks, denominatorBuckets);
3719
+ error = whole.error;
3720
+ value = error || typeof value !== "number" || typeof whole.value !== "number" || whole.value === 0 ? null : value / whole.value;
3083
3721
  }
3084
- return bound(out);
3085
- }
3086
- try {
3087
- const value = run(ast);
3088
- return inspection ? { value, regex: inspection } : { value };
3089
- } catch (e) {
3090
3722
  return {
3091
- value: null,
3092
- error: e instanceof Error ? e.message : "Formula evaluation failed."
3723
+ keys,
3724
+ value: error ? null : value,
3725
+ count: own.count,
3726
+ ...error ? { error } : {},
3727
+ ...own.intervals !== void 0 ? { intervals: own.intervals } : {}
3093
3728
  };
3729
+ });
3730
+ let hiddenGroups;
3731
+ if (c.minCount !== void 0) {
3732
+ const kept = buckets.filter(
3733
+ (b) => b.count === 0 || b.count >= c.minCount
3734
+ );
3735
+ hiddenGroups = buckets.length - kept.length;
3736
+ buckets = kept;
3094
3737
  }
3738
+ sortMetricBuckets(buckets, c);
3739
+ const groupCount = buckets.length;
3740
+ if (c.groupLimit) buckets = buckets.slice(0, c.groupLimit);
3741
+ const first = entries[0];
3742
+ return {
3743
+ id: c.id,
3744
+ buckets,
3745
+ scope: first.scope ?? c.scope ?? "allMatching",
3746
+ ...first.processedRows !== void 0 ? { processedRows: first.processedRows } : {},
3747
+ groupCount,
3748
+ coverage: "exact",
3749
+ ...hiddenGroups !== void 0 ? { hiddenGroups } : {}
3750
+ };
3095
3751
  }
3096
3752
 
3097
3753
  // src/groupKeys.ts
@@ -3793,22 +4449,6 @@ function toServerQueryV2(q, schema, execution) {
3793
4449
  };
3794
4450
  }
3795
4451
 
3796
- // src/metricPrecision.ts
3797
- function assertMetricNumber(value) {
3798
- if (!Number.isFinite(value) || Number.isInteger(value) && !Number.isSafeInteger(value)) {
3799
- throw Error("Aggregate exceeds finite safe numeric precision.");
3800
- }
3801
- }
3802
- function checkedMetricResult(result) {
3803
- if (result.error || typeof result.value !== "number") return result;
3804
- try {
3805
- assertMetricNumber(result.value);
3806
- return result;
3807
- } catch (e) {
3808
- return { value: null, error: e.message };
3809
- }
3810
- }
3811
-
3812
4452
  // src/metricDistributions.ts
3813
4453
  function percentile(sorted, p) {
3814
4454
  if (!Number.isFinite(p) || p < 0 || p > 1)
@@ -3908,176 +4548,6 @@ function histogramCounts(values, edges) {
3908
4548
  return counts;
3909
4549
  }
3910
4550
 
3911
- // src/metricDates.ts
3912
- import { Temporal } from "temporal-polyfill";
3913
- var METRIC_DATE_INTERVALS = [
3914
- "hour",
3915
- "day",
3916
- "week",
3917
- "month",
3918
- "year"
3919
- ];
3920
- function metricDateTime(value, timeZone = "UTC") {
3921
- if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2}))?$/.test(
3922
- value
3923
- ))
3924
- throw Error(
3925
- "Date grouping requires ISO dates or timestamps with a timezone."
3926
- );
3927
- const time = Date.parse(value), month = Number(value.slice(5, 7)), day = Number(value.slice(8, 10));
3928
- const calendar = /* @__PURE__ */ new Date(0);
3929
- calendar.setUTCFullYear(Number(value.slice(0, 4)), month, 0);
3930
- if (!Number.isFinite(time) || month < 1 || month > 12 || day < 1 || day > calendar.getUTCDate())
3931
- throw Error("Invalid date grouping value.");
3932
- return value.length === 10 && timeZone !== "UTC" ? Temporal.PlainDate.from(value).toZonedDateTime(timeZone).epochMilliseconds : time;
3933
- }
3934
- function metricOffsetTransition(start, end, timeZone) {
3935
- if (end <= start) return void 0;
3936
- const offset = (time) => Temporal.Instant.fromEpochMilliseconds(time).toZonedDateTimeISO(timeZone).offsetNanoseconds;
3937
- const before = offset(start);
3938
- if (before === offset(end)) return void 0;
3939
- while (end - start > 1) {
3940
- const middle = Math.floor((start + end) / 2);
3941
- if (offset(middle) === before) start = middle;
3942
- else end = middle;
3943
- }
3944
- return end;
3945
- }
3946
- function metricDateFloor(time, interval, timeZone = "UTC") {
3947
- if (timeZone !== "UTC") {
3948
- const zoned = Temporal.Instant.fromEpochMilliseconds(time).toZonedDateTimeISO(timeZone);
3949
- if (interval === "hour") {
3950
- const floor = time - zoned.minute * 6e4 - zoned.second * 1e3 - zoned.millisecond;
3951
- return metricOffsetTransition(floor, time, timeZone) ?? floor;
3952
- }
3953
- let date2 = zoned.toPlainDate();
3954
- if (interval === "week") date2 = date2.subtract({ days: date2.dayOfWeek - 1 });
3955
- if (interval === "month" || interval === "year")
3956
- date2 = date2.with({ day: 1 });
3957
- if (interval === "year") date2 = date2.with({ month: 1 });
3958
- return date2.toZonedDateTime(timeZone).epochMilliseconds;
3959
- }
3960
- const date = new Date(time);
3961
- date.setUTCMinutes(0, 0, 0);
3962
- if (interval !== "hour") date.setUTCHours(0);
3963
- if (interval === "week")
3964
- date.setUTCDate(date.getUTCDate() - (date.getUTCDay() + 6) % 7);
3965
- if (interval === "month" || interval === "year") date.setUTCDate(1);
3966
- if (interval === "year") date.setUTCMonth(0);
3967
- return date.getTime();
3968
- }
3969
- function metricDateNext(time, interval, timeZone = "UTC") {
3970
- if (timeZone !== "UTC") {
3971
- const zoned = Temporal.Instant.fromEpochMilliseconds(time).toZonedDateTimeISO(timeZone);
3972
- if (interval === "hour") {
3973
- const target = zoned.toPlainDateTime().with({
3974
- minute: 0,
3975
- second: 0,
3976
- millisecond: 0,
3977
- microsecond: 0,
3978
- nanosecond: 0
3979
- }).add({ hours: 1 });
3980
- let next = target.toZonedDateTime(timeZone).epochMilliseconds;
3981
- if (next <= time)
3982
- next = target.toZonedDateTime(timeZone, {
3983
- disambiguation: "later"
3984
- }).epochMilliseconds;
3985
- return metricOffsetTransition(time, next, timeZone) ?? next;
3986
- }
3987
- let date2 = zoned.toPlainDate();
3988
- if (interval === "month") date2 = date2.with({ day: 1 }).add({ months: 1 });
3989
- else if (interval === "year")
3990
- date2 = date2.with({ month: 1, day: 1 }).add({ years: 1 });
3991
- else if (interval === "week")
3992
- date2 = date2.subtract({ days: date2.dayOfWeek - 1 }).add({ weeks: 1 });
3993
- else date2 = date2.add({ days: 1 });
3994
- return date2.toZonedDateTime(timeZone).epochMilliseconds;
3995
- }
3996
- const date = new Date(time);
3997
- if (interval === "hour") date.setUTCHours(date.getUTCHours() + 1);
3998
- else if (interval === "month") date.setUTCMonth(date.getUTCMonth() + 1);
3999
- else if (interval === "year") date.setUTCFullYear(date.getUTCFullYear() + 1);
4000
- else date.setUTCDate(date.getUTCDate() + (interval === "week" ? 7 : 1));
4001
- return date.getTime();
4002
- }
4003
- function customEndTime(value, timeZone = "UTC") {
4004
- const time = metricDateTime(value, timeZone);
4005
- return value.length === 10 ? metricDateNext(time, "day", timeZone) - 1 : time;
4006
- }
4007
- function validateDateGrouping(config) {
4008
- if (typeof config.field !== "string" || !config.field || config.field.length > 256 || !METRIC_DATE_INTERVALS.includes(config.interval) || typeof config.includeEmpty !== "boolean" || !["query", "observed", "custom"].includes(config.range))
4009
- throw Error("Invalid date grouping definition.");
4010
- if (config.timeZone !== void 0) {
4011
- if (typeof config.timeZone !== "string" || !config.timeZone || config.timeZone.length > 80 || /^[+-]/.test(config.timeZone))
4012
- throw Error(
4013
- "Use a valid IANA grouping timezone, such as America/Denver or UTC."
4014
- );
4015
- try {
4016
- new Intl.DateTimeFormat("en-US", { timeZone: config.timeZone });
4017
- } catch {
4018
- throw Error(
4019
- "Use a valid IANA grouping timezone, such as America/Denver or UTC."
4020
- );
4021
- }
4022
- }
4023
- if (config.includeEmpty && config.range === "custom") {
4024
- const start = metricDateTime(config.start ?? "", config.timeZone), end = customEndTime(config.end ?? "", config.timeZone);
4025
- if (end < start)
4026
- throw Error("The end date must be on or after the start date.");
4027
- }
4028
- }
4029
- function metricDateRange(config, query, observed, now, canonical) {
4030
- let min = Infinity, max = -Infinity;
4031
- for (const time of observed) {
4032
- min = Math.min(min, time);
4033
- max = Math.max(max, time);
4034
- }
4035
- if (config.range === "observed")
4036
- return observed.length ? [min, max] : void 0;
4037
- if (config.range === "custom") {
4038
- const start2 = metricDateFloor(
4039
- metricDateTime(config.start ?? "", config.timeZone),
4040
- config.interval,
4041
- config.timeZone
4042
- );
4043
- const end2 = metricDateFloor(
4044
- customEndTime(config.end ?? "", config.timeZone),
4045
- config.interval,
4046
- config.timeZone
4047
- );
4048
- if (observed.length && (start2 > min || end2 < max))
4049
- throw Error(
4050
- "Custom date bounds must include all observed buckets. Narrow the query filters to exclude data."
4051
- );
4052
- return [start2, end2];
4053
- }
4054
- let start = -Infinity, end = Infinity;
4055
- for (const term of query.where) {
4056
- if (isOrGroup(term) || term.negated || canonical(term.field) !== canonical(config.field))
4057
- continue;
4058
- if (!["=", ">", ">=", "<", "<="].includes(term.op)) continue;
4059
- const time = Number(coerceValue("datetime", term.value, now));
4060
- if (term.op === ">" || term.op === ">=" || term.op === "=")
4061
- start = Math.max(start, time + (term.op === ">" ? 1 : 0));
4062
- if (term.op === "<" || term.op === "<=" || term.op === "=")
4063
- end = Math.min(end, time - (term.op === "<" ? 1 : 0));
4064
- }
4065
- if (!Number.isFinite(start) || !Number.isFinite(end)) {
4066
- const missing = [
4067
- ...Number.isFinite(start) ? [] : ["a lower bound (>= or >)"],
4068
- ...Number.isFinite(end) ? [] : ["an upper bound (<= or <)"]
4069
- ].join(" and ");
4070
- throw Error(
4071
- `Include empty dates with range "query" needs a bounded date filter on ${config.field}: add ${missing} as top-level filters (bounds inside OR groups do not count). Otherwise use range "observed" to fill from the first to the last observed date, or range "custom" with start and end.`
4072
- );
4073
- }
4074
- if (end < start) return void 0;
4075
- return [
4076
- metricDateFloor(start, config.interval, config.timeZone),
4077
- metricDateFloor(end, config.interval, config.timeZone)
4078
- ];
4079
- }
4080
-
4081
4551
  // src/metrics.ts
4082
4552
  var aggregates = /* @__PURE__ */ new Set([
4083
4553
  "COUNT",
@@ -4421,6 +4891,7 @@ function validateMetric(c, schema, resolve) {
4421
4891
  const compiled = metricPlans(c, schema, resolve);
4422
4892
  if (c.scope !== "shownRows" && compiled.some(hasRegex))
4423
4893
  throw new FormulaError("Regex is unavailable for all-matching metrics.");
4894
+ validateMetricSeries(c, compiled[0]);
4424
4895
  return [];
4425
4896
  } catch (e) {
4426
4897
  return [
@@ -4484,6 +4955,8 @@ function metricComputationKey(clauses) {
4484
4955
  groupLimit: c.groupLimit,
4485
4956
  where: c.where,
4486
4957
  minCount: c.minCount,
4958
+ window: c.window,
4959
+ share: c.share,
4487
4960
  distribution: distributionActive(c) ? c.distribution : void 0
4488
4961
  })).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
4489
4962
  );
@@ -4535,6 +5008,8 @@ function toMetricQuery(q, schema, execution) {
4535
5008
  expressionY,
4536
5009
  dateGrouping,
4537
5010
  where,
5011
+ window: _window,
5012
+ share: _share,
4538
5013
  ...rest
4539
5014
  } = c;
4540
5015
  return {
@@ -4564,6 +5039,15 @@ function toMetricQuery(q, schema, execution) {
4564
5039
  to: 1,
4565
5040
  message: "Metric filters must use server-filterable fields."
4566
5041
  });
5042
+ for (const c of q.aggregations ?? [])
5043
+ if (hasMetricSeries(c))
5044
+ diagnostics.push({
5045
+ metricId: c.id,
5046
+ code: "unexpanded_series",
5047
+ from: 0,
5048
+ to: 1,
5049
+ message: "Windows, shares and whole intervals are computed from plain metrics; expand the query with planMetricSeries first."
5050
+ });
4567
5051
  for (const c of q.aggregations ?? [])
4568
5052
  if (c.dateGrouping)
4569
5053
  diagnostics.push({
@@ -4759,6 +5243,24 @@ function compareMetricValues(a, b) {
4759
5243
  }
4760
5244
  return a < b ? -1 : 1;
4761
5245
  }
5246
+ function sortMetricBuckets(buckets, c) {
5247
+ const ordering = c.sort?.length ? c.sort : c.dateGrouping ? [{ key: "group0", dir: "asc", nulls: "last" }] : [{ key: "value", dir: "desc", nulls: "last" }];
5248
+ const target = (b, s) => s.key === "samples" ? b.count - (b.nullCount ?? 0) - (b.inputErrorCount ?? 0) : s.key.startsWith("group") ? b.keys[Number(s.key.slice(5))] ?? null : s.key === "count" ? b.count : s.key === "y" ? b.y ?? null : b.value;
5249
+ return buckets.sort((a, b) => {
5250
+ if (!!(a.error || a.yError) !== !!(b.error || b.yError))
5251
+ return a.error || a.yError ? 1 : -1;
5252
+ for (const s of ordering) {
5253
+ const av = target(a, s), bv = target(b, s);
5254
+ const cmp = av === null || bv === null ? av === bv ? 0 : (av === null ? 1 : -1) * (s.nulls === "first" ? -1 : 1) : compareMetricValues(av, bv) * (s.dir === "asc" ? 1 : -1);
5255
+ if (cmp) return cmp;
5256
+ }
5257
+ for (let i = 0; i < a.keys.length; i++) {
5258
+ const cmp = compareMetricValues(a.keys[i], b.keys[i]);
5259
+ if (cmp) return cmp;
5260
+ }
5261
+ return 0;
5262
+ });
5263
+ }
4762
5264
  function evaluateMetrics(rows, q, schema, options2 = {}) {
4763
5265
  if (q.grouping) {
4764
5266
  try {
@@ -4775,6 +5277,18 @@ function evaluateMetrics(rows, q, schema, options2 = {}) {
4775
5277
  }
4776
5278
  }
4777
5279
  const now = evaluationTime(options2), fields = indexFields(schema);
5280
+ const series = planMetricSeries(q, schema, {
5281
+ now,
5282
+ ...options2.resolveComputed ? { resolveComputed: options2.resolveComputed } : {}
5283
+ });
5284
+ if (series)
5285
+ return {
5286
+ metrics: series.combine(
5287
+ series.queries.flatMap(
5288
+ (part) => evaluateMetrics(rows, part, schema, { ...options2, now }).metrics
5289
+ )
5290
+ )
5291
+ };
4778
5292
  let matching;
4779
5293
  let shown;
4780
5294
  return {
@@ -4909,7 +5423,7 @@ function evaluateMetrics(rows, q, schema, options2 = {}) {
4909
5423
  if (c.dateGrouping?.includeEmpty) {
4910
5424
  const config = c.dateGrouping;
4911
5425
  const observed = [];
4912
- const series = /* @__PURE__ */ new Map();
5426
+ const series2 = /* @__PURE__ */ new Map();
4913
5427
  for (const group of groups.values())
4914
5428
  if (group.keys[0] !== null) {
4915
5429
  tick();
@@ -4918,17 +5432,17 @@ function evaluateMetrics(rows, q, schema, options2 = {}) {
4918
5432
  observed.push(metricDateTime(group.keys[0]));
4919
5433
  const tuple = group.keys.slice(1);
4920
5434
  const key = JSON.stringify(tuple);
4921
- if (!series.has(key)) {
5435
+ if (!series2.has(key)) {
4922
5436
  const bytes = 128 + 16 * tuple.length + 2 * key.length;
4923
5437
  reserve(bytes);
4924
5438
  groupBytes += bytes;
4925
- series.set(key, tuple);
5439
+ series2.set(key, tuple);
4926
5440
  }
4927
5441
  }
4928
- if (c.groupBy.length === 1 && !series.size) {
5442
+ if (c.groupBy.length === 1 && !series2.size) {
4929
5443
  reserve(132);
4930
5444
  groupBytes += 132;
4931
- series.set("[]", []);
5445
+ series2.set("[]", []);
4932
5446
  }
4933
5447
  const range = metricDateRange(
4934
5448
  config,
@@ -4940,7 +5454,7 @@ function evaluateMetrics(rows, q, schema, options2 = {}) {
4940
5454
  if (range)
4941
5455
  for (let time = range[0]; time <= range[1]; ) {
4942
5456
  tick();
4943
- for (const tuple of series.values()) {
5457
+ for (const tuple of series2.values()) {
4944
5458
  tick();
4945
5459
  const keys = [
4946
5460
  new Date(time).toISOString(),
@@ -5100,22 +5614,7 @@ function evaluateMetrics(rows, q, schema, options2 = {}) {
5100
5614
  hiddenGroups = buckets.length - kept.length;
5101
5615
  buckets = kept;
5102
5616
  }
5103
- const ordering = c.sort?.length ? c.sort : c.dateGrouping ? [{ key: "group0", dir: "asc", nulls: "last" }] : [{ key: "value", dir: "desc", nulls: "last" }];
5104
- const target = (b, s) => s.key === "samples" ? b.count - (b.nullCount ?? 0) - (b.inputErrorCount ?? 0) : s.key.startsWith("group") ? b.keys[Number(s.key.slice(5))] ?? null : s.key === "count" ? b.count : s.key === "y" ? b.y ?? null : b.value;
5105
- buckets.sort((a, b) => {
5106
- if (!!(a.error || a.yError) !== !!(b.error || b.yError))
5107
- return a.error || a.yError ? 1 : -1;
5108
- for (const s of ordering) {
5109
- const av = target(a, s), bv = target(b, s);
5110
- const cmp = av === null || bv === null ? av === bv ? 0 : (av === null ? 1 : -1) * (s.nulls === "first" ? -1 : 1) : compareMetricValues(av, bv) * (s.dir === "asc" ? 1 : -1);
5111
- if (cmp) return cmp;
5112
- }
5113
- for (let i = 0; i < a.keys.length; i++) {
5114
- const cmp = compareMetricValues(a.keys[i], b.keys[i]);
5115
- if (cmp) return cmp;
5116
- }
5117
- return 0;
5118
- });
5617
+ sortMetricBuckets(buckets, c);
5119
5618
  tick();
5120
5619
  const groupCount = buckets.length;
5121
5620
  let other;
@@ -5223,7 +5722,7 @@ function applyAggregations(rows, q, schema, options2 = {}) {
5223
5722
  ))
5224
5723
  return evaluateMetrics(rows, q, schema, options2);
5225
5724
  const advanced = (q.aggregations ?? []).filter(
5226
- (a) => a.dateGrouping || a.diagnostics?.length || a.expression !== void 0 || a.expressionY !== void 0 || a.scope !== void 0 || a.distribution || a.sort || a.groupLimit !== void 0 || a.where !== void 0 || a.minCount !== void 0
5725
+ (a) => a.dateGrouping || a.diagnostics?.length || a.expression !== void 0 || a.expressionY !== void 0 || a.scope !== void 0 || a.distribution || a.sort || a.groupLimit !== void 0 || a.where !== void 0 || a.minCount !== void 0 || hasMetricSeries(a)
5227
5726
  );
5228
5727
  if (advanced.length) {
5229
5728
  if ((q.aggregations?.length ?? 0) > 20 || normalizeQueryState(q).aggregations?.length !== q.aggregations?.length)
@@ -6149,7 +6648,7 @@ function toQueryRequestV3(query, baseSchema, capabilities, execution, options2 =
6149
6648
  if (predicate.field.startsWith("@computed/") && !capabilities.computedSourceFilters) diagnostics.push("This server does not support computed source predicates.");
6150
6649
  }
6151
6650
  const metricQuery = toMetricQuery(withoutGrouping(q), resultSchema, execution);
6152
- diagnostics.push(...metricQuery.diagnostics.filter((issue) => issue.code === "residual_metric_filter").map((issue) => issue.message));
6651
+ diagnostics.push(...metricQuery.diagnostics.filter((issue) => issue.code === "residual_metric_filter" || issue.code === "unexpanded_series").map((issue) => issue.message));
6153
6652
  const metrics = metricQuery.metrics.map((metric) => {
6154
6653
  const dateGrouping = q.aggregations?.find((clause) => clause.id === metric.id)?.dateGrouping;
6155
6654
  return dateGrouping ? { ...metric, dateGrouping } : metric;
@@ -6223,12 +6722,21 @@ function inspectBucketQuery(query, bucket, clause, schema) {
6223
6722
  } catch {
6224
6723
  return null;
6225
6724
  }
6226
- const { interval, timeZone } = clause.dateGrouping;
6725
+ const { interval, timeZone, partialIntervals } = clause.dateGrouping;
6227
6726
  const next = metricDateNext(start, interval, timeZone);
6228
- terms.push(
6229
- { field: field.name, op: ">=", value: new Date(start).toISOString() },
6230
- { field: field.name, op: "<", value: new Date(next).toISOString() }
6231
- );
6727
+ let from = start;
6728
+ const periods = clause.window?.periods;
6729
+ if (periods === "all") from = void 0;
6730
+ else if (periods !== void 0) {
6731
+ if (!Number.isInteger(periods) || periods < 2 || periods > MAX_METRIC_WINDOW_PERIODS) return null;
6732
+ for (let i = 1; i < periods; i++) from = metricDateFloor(from - 1, interval, timeZone);
6733
+ }
6734
+ if (partialIntervals === "exclude") {
6735
+ const whole = wholeIntervalSpan(clause, query, fields, Date.now()).start;
6736
+ if (whole !== void 0) from = Math.max(from ?? whole, whole);
6737
+ }
6738
+ if (from !== void 0) terms.push({ field: field.name, op: ">=", value: new Date(from).toISOString() });
6739
+ terms.push({ field: field.name, op: "<", value: new Date(next).toISOString() });
6232
6740
  continue;
6233
6741
  }
6234
6742
  const term = keyClause(field, key);
@@ -6273,6 +6781,7 @@ export {
6273
6781
  ITEM_DETAILS_COLUMN,
6274
6782
  MAX_AGGREGATIONS,
6275
6783
  MAX_GROUP_BY_FIELDS,
6784
+ MAX_METRIC_WINDOW_PERIODS,
6276
6785
  MAX_ORDER_BY_TERMS,
6277
6786
  MAX_PROPERTIES,
6278
6787
  MAX_QUERY_LIMIT,
@@ -6320,6 +6829,7 @@ export {
6320
6829
  groupLinkQuery,
6321
6830
  groupPreview,
6322
6831
  groupSourceQuery,
6832
+ hasMetricSeries,
6323
6833
  hasRegex,
6324
6834
  histogramCounts,
6325
6835
  histogramEdges,
@@ -6354,6 +6864,7 @@ export {
6354
6864
  memoryStorageAdapter,
6355
6865
  metricAggregatePlans,
6356
6866
  metricComputationKey,
6867
+ metricCustomEndTime,
6357
6868
  metricDateFloor,
6358
6869
  metricDateNext,
6359
6870
  metricDateRange,
@@ -6372,6 +6883,7 @@ export {
6372
6883
  opsForField,
6373
6884
  parseRelativeDuration,
6374
6885
  percentile,
6886
+ planMetricSeries,
6375
6887
  predicatesOf,
6376
6888
  prepareGrouping,
6377
6889
  queriesEqual,
@@ -6387,6 +6899,7 @@ export {
6387
6899
  selectedFields,
6388
6900
  setFilterModes,
6389
6901
  setFilterTarget,
6902
+ sortMetricBuckets,
6390
6903
  sourceDependencies,
6391
6904
  sourceExecutionSchema,
6392
6905
  stripSetFilter,
@@ -6399,8 +6912,10 @@ export {
6399
6912
  validateDateGrouping,
6400
6913
  validateItemPageTemplate,
6401
6914
  validateMetric,
6915
+ validateMetricSeries,
6402
6916
  validateQueryResultV3,
6403
6917
  validateQuerySnapshot,
6918
+ wholeIntervalSpan,
6404
6919
  withGroupingKeys,
6405
6920
  withoutGrouping,
6406
6921
  withoutLinkSummaries