@pythia-software/query-table-core 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ var options = {
16
16
  number: numberOptions,
17
17
  percent: numberOptions,
18
18
  chip: { labels: "labels", tones: "tones" },
19
- text: { truncate: "boolean" }
19
+ text: { truncate: "boolean", lines: [1, 20] }
20
20
  };
21
21
  function parseCellDisplay(raw, context = "cell display") {
22
22
  const fail = (reason) => {
@@ -385,42 +385,63 @@ function normalizeAliases(rawAliases, rawAlias) {
385
385
  }
386
386
 
387
387
  // src/itemPage.ts
388
- function itemPageFields(template) {
388
+ var MAX_TEMPLATE_LENGTH = 8e3;
389
+ var BASE = "https://query-table.invalid/";
390
+ var ITEM_PAGE_TOKENS = ["id", "query"];
391
+ function placeholders(tokens) {
392
+ return new RegExp(`\\{(${[...tokens, "field:[^{}]+"].join("|")})\\}`, "g");
393
+ }
394
+ function httpOrRelative(href) {
395
+ try {
396
+ return ["http:", "https:"].includes(new URL(href, BASE).protocol);
397
+ } catch {
398
+ return false;
399
+ }
400
+ }
401
+ function templateFields(template) {
389
402
  return [...new Set(Array.from(template.matchAll(/\{field:([^{}]+)\}/g), (match) => match[1]))];
390
403
  }
391
- function validateItemPageTemplate(template, schema) {
392
- if (template.length > 8e3) return "Use a URL template of at most 8,000 characters.";
404
+ function urlTemplateProblem(template, tokens, hasField) {
405
+ if (template.length > MAX_TEMPLATE_LENGTH) return "Use a URL template of at most 8,000 characters.";
393
406
  if (!template.trim()) return null;
394
- const remainder = template.replace(/\{(id|query|field:[^{}]+)\}/g, "value");
395
- if (/[{}]/.test(remainder)) return "Use {id}, {query}, or {field:field_name} placeholders.";
396
- for (const name of itemPageFields(template)) {
397
- if (!schema.fields.some((field) => field.name === name)) return `Unknown URL field: ${name}.`;
407
+ const remainder = template.replace(placeholders(tokens), "value");
408
+ if (/[{}]/.test(remainder)) {
409
+ const names = [...tokens.map((token) => `{${token}}`), "{field:field_name}"];
410
+ const list = names.length > 1 ? `${names.slice(0, -1).join(", ")}${names.length > 2 ? "," : ""} or ${names.at(-1)}` : names[0];
411
+ return `Use ${list} placeholders.`;
412
+ }
413
+ for (const name of templateFields(template)) {
414
+ if (!hasField(name)) return `Unknown URL field: ${name}.`;
398
415
  }
399
416
  try {
400
- if (!["http:", "https:"].includes(new URL(remainder, "https://query-table.invalid/").protocol))
417
+ if (!["http:", "https:"].includes(new URL(remainder, BASE).protocol))
401
418
  return "Use an HTTP, HTTPS, or relative page URL.";
402
419
  } catch {
403
420
  return "Enter a valid page URL.";
404
421
  }
405
422
  return null;
406
423
  }
407
- function renderItemPageTemplate(template, schema, row, id, query) {
408
- if (!template.trim() || validateItemPageTemplate(template, schema)) return null;
424
+ function fillUrlTemplate(template, tokens, read) {
409
425
  let missing = false;
410
- const href = template.replace(/\{(id|query|field:[^{}]+)\}/g, (_match, token) => {
411
- const value = token === "id" ? id : token === "query" ? query : readFieldValue(schema.fields.find((field) => field.name === token.slice(6)), row);
426
+ const href = template.replace(placeholders(tokens), (_match, token) => {
427
+ const value = read(token);
412
428
  if (value == null || !["string", "number", "boolean"].includes(typeof value)) {
413
429
  missing = true;
414
430
  return "";
415
431
  }
416
432
  return encodeURIComponent(String(value));
417
433
  });
418
- try {
419
- if (missing || !["http:", "https:"].includes(new URL(href, "https://query-table.invalid/").protocol)) return null;
420
- } catch {
421
- return null;
422
- }
423
- return href;
434
+ return missing || !httpOrRelative(href) ? null : href;
435
+ }
436
+ function itemPageFields(template) {
437
+ return templateFields(template);
438
+ }
439
+ function validateItemPageTemplate(template, schema) {
440
+ return urlTemplateProblem(template, ITEM_PAGE_TOKENS, (name) => schema.fields.some((field) => field.name === name));
441
+ }
442
+ function renderItemPageTemplate(template, schema, row, id, query) {
443
+ if (!template.trim() || validateItemPageTemplate(template, schema)) return null;
444
+ return fillUrlTemplate(template, ITEM_PAGE_TOKENS, (token) => token === "id" ? id : token === "query" ? query : readFieldValue(schema.fields.find((field) => field.name === token.slice(6)), row));
424
445
  }
425
446
 
426
447
  // src/metricNormalize.ts
@@ -444,7 +465,8 @@ function normalizeMetricOptions(raw) {
444
465
  expression: "string",
445
466
  expressionY: "string",
446
467
  scope: ["allMatching", "shownRows"],
447
- groupLimit: "integer"
468
+ groupLimit: "integer",
469
+ minCount: "positiveInteger"
448
470
  })
449
471
  );
450
472
  if (raw.dateGrouping !== void 0) {
@@ -666,7 +688,50 @@ function normalizeSetFilter(item) {
666
688
  if (item.values.length > MAX_WHERE_CLAUSES || !item.values.every((value) => typeof value === "string" && value.length <= MAX_FILTER_VALUE_LENGTH)) return void 0;
667
689
  return { id, mode: item.mode, values: [...item.values] };
668
690
  }
691
+ function normalizeWhereTerms(input) {
692
+ const where = [];
693
+ let literalBudget = MAX_WHERE_CLAUSES;
694
+ for (const item of input) {
695
+ if (literalBudget <= 0) break;
696
+ const start = where.length;
697
+ if (isRecord(item) && Array.isArray(item.any)) {
698
+ const members = [];
699
+ for (const inner of item.any) {
700
+ if (literalBudget <= 0) break;
701
+ const clause = normalizeWhereClause(inner);
702
+ if (clause) {
703
+ members.push(clause);
704
+ literalBudget--;
705
+ }
706
+ }
707
+ if (members.length === 1) where.push(members[0]);
708
+ else if (members.length > 1) where.push({ any: members });
709
+ } else {
710
+ const clause = normalizeWhereClause(item);
711
+ if (clause) {
712
+ where.push(clause);
713
+ literalBudget--;
714
+ }
715
+ }
716
+ if (where.length > start && isRecord(item)) {
717
+ const metadata = normalizeSetFilter(item.setFilter);
718
+ if (metadata) where[start].setFilter = metadata;
719
+ }
720
+ }
721
+ return where;
722
+ }
723
+ function normalizeMetricWhere(value) {
724
+ if (value === void 0) return { invalid: false };
725
+ if (!Array.isArray(value)) return { invalid: true };
726
+ const where = normalizeWhereTerms(value);
727
+ const supplied = value.reduce((count, term) => count + (isRecord(term) && Array.isArray(term.any) ? term.any.length : 1), 0);
728
+ const kept = where.reduce((count, term) => count + predicatesOf(term).length, 0);
729
+ return { ...where.length ? { where } : {}, invalid: supplied !== kept };
730
+ }
669
731
  function normalizeQueryState(input, fallback = EMPTY_QUERY) {
732
+ return normalizeQuery(input, fallback, true);
733
+ }
734
+ function normalizeQuery(input, fallback, links) {
670
735
  const raw = isRecord(input) ? input : {};
671
736
  const select = [];
672
737
  if (Array.isArray(raw.select)) {
@@ -678,44 +743,18 @@ function normalizeQueryState(input, fallback = EMPTY_QUERY) {
678
743
  if (typeof item.width === "number" && Number.isFinite(item.width)) {
679
744
  column.width = boundedInteger(item.width, MIN_COLUMN_WIDTH, MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH);
680
745
  }
681
- select.push(column);
682
- }
683
- } else {
684
- select.push(...fallback.select.map((column) => ({ ...column })));
685
- }
686
- const where = [];
687
- if (Array.isArray(raw.where)) {
688
- let literalBudget = MAX_WHERE_CLAUSES;
689
- for (const item of raw.where) {
690
- if (literalBudget <= 0) break;
691
- const start = where.length;
692
- if (isRecord(item) && Array.isArray(item.any)) {
693
- const members = [];
694
- for (const inner of item.any) {
695
- if (literalBudget <= 0) break;
696
- const clause = normalizeWhereClause(inner);
697
- if (clause) {
698
- members.push(clause);
699
- literalBudget--;
700
- }
701
- }
702
- if (members.length === 1) where.push(members[0]);
703
- else if (members.length > 1) where.push({ any: members });
704
- } else {
705
- const clause = normalizeWhereClause(item);
706
- if (clause) {
707
- where.push(clause);
708
- literalBudget--;
746
+ if (item.display !== void 0) {
747
+ try {
748
+ column.display = parseCellDisplay(item.display, `column ${field} display`);
749
+ } catch {
709
750
  }
710
751
  }
711
- if (where.length > start && isRecord(item)) {
712
- const metadata = normalizeSetFilter(item.setFilter);
713
- if (metadata) where[start].setFilter = metadata;
714
- }
752
+ select.push(column);
715
753
  }
716
754
  } else {
717
- where.push(...fallback.where.map(cloneWhereTerm));
755
+ select.push(...fallback.select.map((column) => ({ ...column, ...column.display ? { display: JSON.parse(JSON.stringify(column.display)) } : {} })));
718
756
  }
757
+ const where = Array.isArray(raw.where) ? normalizeWhereTerms(raw.where) : fallback.where.map(cloneWhereTerm);
719
758
  const orderBy = [];
720
759
  if (Array.isArray(raw.orderBy)) {
721
760
  for (const item of raw.orderBy.slice(0, MAX_ORDER_BY_TERMS)) {
@@ -742,7 +781,7 @@ function normalizeQueryState(input, fallback = EMPTY_QUERY) {
742
781
  offset: boundedInteger(raw.offset, fallback.offset, 0, MAX_QUERY_OFFSET)
743
782
  };
744
783
  if (raw.fitToScreen === true) out.fitToScreen = true;
745
- if (raw.grouping != null) out.grouping = normalizeGroupingState(raw.grouping);
784
+ if (raw.grouping != null) out.grouping = normalizeGroupingState(raw.grouping, links);
746
785
  if (Array.isArray(raw.aggregations)) {
747
786
  const aggregations = [];
748
787
  for (const item of raw.aggregations.slice(0, MAX_AGGREGATIONS)) {
@@ -758,6 +797,9 @@ function normalizeQueryState(input, fallback = EMPTY_QUERY) {
758
797
  if (field) aggregation.field = field;
759
798
  if (label) aggregation.label = label;
760
799
  Object.assign(aggregation, normalizeMetricOptions(item));
800
+ const metricWhere = normalizeMetricWhere(item.where);
801
+ if (metricWhere.where) aggregation.where = metricWhere.where;
802
+ if (metricWhere.invalid) aggregation.diagnostics = [...aggregation.diagnostics ?? [], "Invalid metric filter."];
761
803
  if (!Array.isArray(item.groupBy) || rawGroups.length > MAX_GROUP_BY_FIELDS || groupBy.length !== rawGroups.length) aggregation.diagnostics = [...aggregation.diagnostics ?? [], "Invalid metric grouping fields or grouping budget exceeded."];
762
804
  const unsupportedOp = boundedString(item.unsupportedOp ?? (!validOp ? item.op : void 0), MAX_FIELD_NAME_LENGTH);
763
805
  if (unsupportedOp) aggregation.unsupportedOp = unsupportedOp;
@@ -804,60 +846,147 @@ function normalizeQueryState(input, fallback = EMPTY_QUERY) {
804
846
  }
805
847
  return out;
806
848
  }
807
- function normalizeGroupingState(value) {
849
+ function normalizeGroupingState(value, links) {
808
850
  const raw = isRecord(value) ? value : {};
809
851
  const diagnostics = [];
810
- const fail = () => {
811
- diagnostics.push("Invalid grouping definition.");
852
+ const fail = (message) => {
853
+ diagnostics.push(`Invalid grouping: ${message}`);
812
854
  };
813
855
  const keys = Array.isArray(raw.keys) ? raw.keys.slice(0, MAX_GROUP_BY_FIELDS).filter((key) => !!boundedString(key, MAX_FIELD_NAME_LENGTH)) : [];
814
- if (!Array.isArray(raw.keys) || !keys.length || keys.length !== raw.keys.length || new Set(keys).size !== keys.length) fail();
856
+ if (!Array.isArray(raw.keys) || !keys.length || keys.length !== raw.keys.length || new Set(keys).size !== keys.length)
857
+ fail(`keys must list 1\u2013${MAX_GROUP_BY_FIELDS} distinct field names.`);
815
858
  const summaries = [];
816
- if (!Array.isArray(raw.summaries) || raw.summaries.length > 64) fail();
817
- for (const item of Array.isArray(raw.summaries) ? raw.summaries.slice(0, 64) : []) {
818
- if (!isRecord(item) || !boundedString(item.id, MAX_FIELD_NAME_LENGTH) || typeof item.op !== "string" || !AGG_OPS.has(item.op) && item.op !== "row" && item.op !== "formula") {
819
- fail();
859
+ if (!Array.isArray(raw.summaries) || raw.summaries.length > 64) fail("summaries must be a list of at most 64 columns.");
860
+ const summaryIds = /* @__PURE__ */ new Set();
861
+ for (const [index, item] of (Array.isArray(raw.summaries) ? raw.summaries.slice(0, 64) : []).entries()) {
862
+ const name = isRecord(item) && boundedString(item.id, MAX_FIELD_NAME_LENGTH) ? `summary "${item.id}"` : `summary ${index + 1}`;
863
+ if (!isRecord(item) || !boundedString(item.id, MAX_FIELD_NAME_LENGTH)) {
864
+ fail(`${name} needs an id of 1\u2013${MAX_FIELD_NAME_LENGTH} characters.`);
865
+ continue;
866
+ }
867
+ if (typeof item.op !== "string" || !AGG_OPS.has(item.op) && item.op !== "row" && item.op !== "formula" && (item.op !== "link" || !links)) {
868
+ fail(item.op === "link" ? `${name} is a link column; a link's patch cannot contain link columns.` : `${name} has unsupported op ${JSON.stringify(String(item.op).slice(0, 40))}; use count, count_distinct, sum, avg, min, max, row, formula, or link${String(item.op).toLowerCase() === "median" ? ' (for a median, use op "formula" with expression "MEDIAN([field])")' : ""}.`);
820
869
  continue;
821
870
  }
822
871
  const column = { id: item.id, op: item.op };
872
+ if (summaryIds.has(column.id)) fail(`${name} uses the same id as another summary column.`);
873
+ else if (keys.includes(column.id)) fail(`${name} uses the same id as group key "${column.id}"; summary ids must differ from source field names.`);
874
+ summaryIds.add(column.id);
875
+ if (item.op === "link") {
876
+ const link = normalizeLinkSpec(item.link);
877
+ if (link) column.link = link;
878
+ if (item.label !== void 0) {
879
+ if (typeof item.label !== "string" || item.label.length > MAX_LABEL_LENGTH) fail(`${name} label must be text of at most ${MAX_LABEL_LENGTH.toLocaleString("en-US")} characters.`);
880
+ else column.label = item.label;
881
+ }
882
+ summaries.push(column);
883
+ continue;
884
+ }
823
885
  if (item.field !== void 0) {
824
- if (!boundedString(item.field, MAX_FIELD_NAME_LENGTH)) fail();
886
+ if (!boundedString(item.field, MAX_FIELD_NAME_LENGTH)) fail(`${name} has an invalid field name.`);
825
887
  else column.field = item.field;
826
888
  }
827
889
  if (item.op === "formula") {
828
- if (typeof item.expression !== "string" || item.expression.length > 1e4) fail();
890
+ if (typeof item.expression !== "string" || item.expression.length > 1e4) fail(`${name} needs an expression of at most 10,000 characters.`);
829
891
  else column.expression = item.expression;
830
892
  }
831
893
  if (item.label !== void 0) {
832
- if (typeof item.label !== "string" || item.label.length > MAX_LABEL_LENGTH) fail();
894
+ if (typeof item.label !== "string" || item.label.length > MAX_LABEL_LENGTH) fail(`${name} label must be text of at most ${MAX_LABEL_LENGTH.toLocaleString("en-US")} characters.`);
833
895
  else column.label = item.label;
834
896
  }
897
+ if (item.display !== void 0) {
898
+ try {
899
+ column.display = parseCellDisplay(item.display, `${name} display`);
900
+ } catch (error) {
901
+ fail(`${error instanceof Error ? error.message : String(error)}.`);
902
+ }
903
+ }
835
904
  summaries.push(column);
836
905
  }
837
906
  let representative = null;
838
907
  if (raw.representative != null) {
839
908
  const ordering = isRecord(raw.representative) ? raw.representative.orderBy : void 0;
840
909
  const orderBy = [];
841
- if (!Array.isArray(ordering) || !ordering.length || ordering.length > 8) fail();
910
+ if (!Array.isArray(ordering) || !ordering.length || ordering.length > 8) fail("representative ordering must list 1\u20138 fields.");
842
911
  for (const term of Array.isArray(ordering) ? ordering.slice(0, 8) : []) {
843
912
  if (!isRecord(term) || !boundedString(term.field, MAX_FIELD_NAME_LENGTH) || !["asc", "desc"].includes(String(term.dir)) || term.nulls !== void 0 && !["first", "last"].includes(String(term.nulls))) {
844
- fail();
913
+ fail('each representative ordering term needs a field, dir "asc" or "desc", and optional nulls "first" or "last".');
845
914
  continue;
846
915
  }
847
916
  orderBy.push({ field: term.field, dir: term.dir, nulls: term.nulls === "first" ? "first" : "last" });
848
917
  }
849
918
  representative = { orderBy };
850
919
  }
851
- if (!isRecord(raw.source) || raw.source.grouping != null) fail();
920
+ if (!isRecord(raw.source)) fail("source must be the record query that is grouped.");
921
+ else if (raw.source.grouping != null) fail("only one grouping stage is supported; the source query cannot itself be grouped.");
852
922
  const { grouping: _nested, ...source } = isRecord(raw.source) ? raw.source : {};
853
- if (Array.isArray(raw.diagnostics) && raw.diagnostics.length) fail();
923
+ if (Array.isArray(raw.diagnostics))
924
+ for (const message of raw.diagnostics.slice(0, 20))
925
+ diagnostics.push(typeof message === "string" && message ? message.slice(0, MAX_LABEL_LENGTH) : "Invalid grouping definition.");
854
926
  const normalizedSource = normalizeQueryState(source);
855
927
  if (Array.isArray(source.where)) {
856
928
  const supplied = source.where.reduce((count, term) => count + (isRecord(term) && Array.isArray(term.any) ? term.any.length : 1), 0);
857
- if (supplied !== normalizedSource.where.reduce((count, term) => count + predicatesOf(term).length, 0)) fail();
929
+ if (supplied !== normalizedSource.where.reduce((count, term) => count + predicatesOf(term).length, 0)) fail("source filters contain malformed predicates or exceed the predicate budget.");
858
930
  }
859
931
  return { keys, summaries, representative, source: normalizedSource, ...diagnostics.length ? { diagnostics: [...new Set(diagnostics)] } : {} };
860
932
  }
933
+ var QUERY_FACETS = /* @__PURE__ */ new Set(["filters", "columns", "order", "grouping", "metrics", "limit"]);
934
+ var PATCH_QUERY_KEYS = ["select", "where", "orderBy", "limit", "aggregations", "grouping", "fitToScreen"];
935
+ var MAX_LINK_PARAMS = 50;
936
+ function normalizeLinkSpec(value) {
937
+ if (!isRecord(value) || !isRecord(value.to)) return void 0;
938
+ let label;
939
+ if (typeof value.label === "string" && value.label.length <= MAX_LABEL_LENGTH) label = value.label;
940
+ else if (isRecord(value.label) && boundedString(value.label.column, MAX_FIELD_NAME_LENGTH)) label = { column: value.label.column };
941
+ else return void 0;
942
+ const to = value.to;
943
+ if (to.kind === "template") {
944
+ if (typeof to.template !== "string" || to.template.length > 8e3) return void 0;
945
+ return { label, to: { kind: "template", template: to.template } };
946
+ }
947
+ if (to.kind !== "query" || !boundedString(to.target, MAX_FIELD_NAME_LENGTH)) return void 0;
948
+ const destination = { kind: "query", target: to.target };
949
+ if (to.base !== void 0) {
950
+ if (to.base !== "source" && to.base !== "empty") return void 0;
951
+ destination.base = to.base;
952
+ }
953
+ if (to.keyFilters !== void 0) {
954
+ if (typeof to.keyFilters !== "boolean") return void 0;
955
+ destination.keyFilters = to.keyFilters;
956
+ }
957
+ if (to.patch !== void 0) {
958
+ const patch = normalizeLinkPatch(to.patch);
959
+ if (!patch) return void 0;
960
+ destination.patch = patch;
961
+ }
962
+ if (to.params !== void 0) {
963
+ if (!isRecord(to.params)) return void 0;
964
+ const entries = Object.entries(to.params);
965
+ if (entries.length > MAX_LINK_PARAMS || entries.some(([key, item]) => !boundedString(key, MAX_FIELD_NAME_LENGTH) || typeof item !== "string" || item.length > MAX_FILTER_VALUE_LENGTH))
966
+ return void 0;
967
+ destination.params = Object.fromEntries(entries);
968
+ }
969
+ return { label, to: destination };
970
+ }
971
+ function keptEveryTerm(supplied, kept) {
972
+ if (!Array.isArray(supplied)) return false;
973
+ const count = supplied.reduce((total, term) => total + (isRecord(term) && Array.isArray(term.any) ? Math.max(1, term.any.length) : 1), 0);
974
+ return count === kept.reduce((total, term) => total + predicatesOf(term).length, 0);
975
+ }
976
+ function normalizeLinkPatch(value) {
977
+ if (!isRecord(value) || !boundedString(value.id, MAX_FIELD_NAME_LENGTH) || !Array.isArray(value.owns) || !isRecord(value.query)) return void 0;
978
+ if (value.owns.length > QUERY_FACETS.size || value.owns.some((facet) => typeof facet !== "string" || !QUERY_FACETS.has(facet))) return void 0;
979
+ const raw = value.query;
980
+ const normalized = normalizeQuery(raw, EMPTY_QUERY, false);
981
+ if (normalized.grouping?.diagnostics) return void 0;
982
+ if (raw.where !== void 0 && !keptEveryTerm(raw.where, normalized.where)) return void 0;
983
+ if (raw.aggregations !== void 0 && (!Array.isArray(raw.aggregations) || raw.aggregations.length !== (normalized.aggregations?.length ?? 0) || normalized.aggregations?.some((clause) => clause.diagnostics?.length))) return void 0;
984
+ const query = {};
985
+ for (const key of PATCH_QUERY_KEYS) {
986
+ if (raw[key] !== void 0 && normalized[key] !== void 0) query[key] = normalized[key];
987
+ }
988
+ return { id: value.id, owns: [...new Set(value.owns)], query };
989
+ }
861
990
  function queriesEqual(a, b) {
862
991
  if (a === b) return true;
863
992
  if (a.limit !== b.limit || a.offset !== b.offset) return false;
@@ -1051,15 +1180,26 @@ function isGroupable(field) {
1051
1180
  import { deflateSync, Inflate } from "fflate";
1052
1181
 
1053
1182
  // src/setFilter.ts
1054
- function createSetFilter(field, mode, values, id) {
1183
+ function setFilterTarget(field) {
1184
+ if (field.filter?.editor !== "set") return null;
1185
+ if (field.type === "textarray") return "array";
1186
+ if (field.type === "enum" || field.type === "text" && filterValues(field).source === "static") return "scalar";
1187
+ return null;
1188
+ }
1189
+ function setFilterModes(target) {
1190
+ return target === "array" ? ["any", "all", "none", "empty"] : ["any", "none"];
1191
+ }
1192
+ function createSetFilter(field, mode, values, id, target = "array") {
1193
+ if (!setFilterModes(target).includes(mode)) throw new Error(`${mode.toUpperCase()} set filters require a textarray field.`);
1055
1194
  const keys = [...new Set(values)];
1056
1195
  if (mode !== "empty" && keys.length === 0) return [];
1057
- const includes = keys.map((value) => ({ field, op: "includes", value }));
1196
+ const members = keys.map((value) => ({ field, op: target === "array" ? "includes" : "=", value }));
1197
+ const isNull = { field, op: "is_null", value: "" };
1058
1198
  let terms;
1059
- if (mode === "empty") terms = [{ field, op: "is_null", value: "" }];
1060
- else if (mode === "any") terms = includes.length === 1 ? includes : [{ any: includes }];
1061
- else if (mode === "all") terms = includes;
1062
- else terms = includes.map((clause) => ({ any: [{ field, op: "is_null", value: "" }, { ...clause, negated: true }] }));
1199
+ if (mode === "empty") terms = [isNull];
1200
+ else if (mode === "any") terms = members.length === 1 ? members : [{ any: members }];
1201
+ else if (mode === "all") terms = members;
1202
+ else terms = members.map((clause) => ({ any: [isNull, target === "array" ? { ...clause, negated: true } : { ...clause, op: "!=" }] }));
1063
1203
  return terms.map((term) => ({ ...term, setFilter: { id, mode, values: mode === "empty" ? [] : [...keys] } }));
1064
1204
  }
1065
1205
  function stripSetFilter(term) {
@@ -1072,14 +1212,15 @@ function readSetFilter(where, index) {
1072
1212
  if (!term || !metadata || where[index - 1]?.setFilter?.id === metadata.id) return null;
1073
1213
  const field = predicatesOf(term)[0]?.field;
1074
1214
  if (!field) return null;
1075
- const expected = createSetFilter(field, metadata.mode, metadata.values, metadata.id);
1215
+ const target = predicatesOf(term).some((clause) => clause.op === "includes") || !setFilterModes("scalar").includes(metadata.mode) ? "array" : "scalar";
1216
+ const expected = createSetFilter(field, metadata.mode, metadata.values, metadata.id, target);
1076
1217
  if (expected.length === 0) return null;
1077
1218
  for (let offset = 0; offset < expected.length; offset++) {
1078
1219
  const actual = where[index + offset];
1079
1220
  if (!actual || JSON.stringify(actual.setFilter) !== JSON.stringify(expected[offset].setFilter) || JSON.stringify(stripSetFilter(actual)) !== JSON.stringify(stripSetFilter(expected[offset]))) return null;
1080
1221
  }
1081
1222
  if (where[index + expected.length]?.setFilter?.id === metadata.id) return null;
1082
- return { field, metadata, count: expected.length };
1223
+ return { field, metadata, count: expected.length, target };
1083
1224
  }
1084
1225
  function mapSetFilterValues(where, canonicalize) {
1085
1226
  const result = [];
@@ -1089,7 +1230,7 @@ function mapSetFilterValues(where, canonicalize) {
1089
1230
  result.push(where[index]);
1090
1231
  continue;
1091
1232
  }
1092
- result.push(...createSetFilter(set.field, set.metadata.mode, set.metadata.values.map((value) => canonicalize(set.field, value)), set.metadata.id));
1233
+ result.push(...createSetFilter(set.field, set.metadata.mode, set.metadata.values.map((value) => canonicalize(set.field, value)), set.metadata.id, set.target));
1093
1234
  index += set.count - 1;
1094
1235
  }
1095
1236
  return result;
@@ -1119,7 +1260,7 @@ function compactQuery(q) {
1119
1260
  q = normalizeQueryState(q);
1120
1261
  const c = {};
1121
1262
  if (q.select.length)
1122
- c.s = q.select.map((col) => col.width != null ? [col.field, col.width] : [col.field]);
1263
+ c.s = q.select.map((col) => col.display ? [col.field, col.width ?? null, col.display] : col.width != null ? [col.field, col.width] : [col.field]);
1123
1264
  if (q.where.length) c.w = q.where;
1124
1265
  if (q.orderBy.length) c.o = q.orderBy;
1125
1266
  c.l = q.limit;
@@ -1145,7 +1286,7 @@ function packQuery(c) {
1145
1286
  const terms = [];
1146
1287
  for (let i = 0; i < c.w.length; i++) {
1147
1288
  const set = readSetFilter(c.w, i);
1148
- if (set) {
1289
+ if (set?.target === "array") {
1149
1290
  terms.push(["set", set.field, set.metadata.mode, set.metadata.values, set.metadata.id]);
1150
1291
  i += set.count - 1;
1151
1292
  } else terms.push(c.w[i]);
@@ -1283,7 +1424,10 @@ function normalizeSelect(s) {
1283
1424
  return s.map((item) => {
1284
1425
  if (typeof item === "string") return { field: item };
1285
1426
  if (Array.isArray(item) && typeof item[0] === "string") {
1286
- return typeof item[1] === "number" ? { field: item[0], width: item[1] } : { field: item[0] };
1427
+ const column = { field: item[0] };
1428
+ if (typeof item[1] === "number") column.width = item[1];
1429
+ if (item[2] !== void 0) column.display = item[2];
1430
+ return column;
1287
1431
  }
1288
1432
  return null;
1289
1433
  }).filter((x) => x != null);
@@ -1337,7 +1481,7 @@ function toAggregationQuery(q, schema) {
1337
1481
  const suppliedPredicateCount = q.where.reduce((n, t) => n + predicatesOf(t).length, 0);
1338
1482
  q = normalizeQueryState(q);
1339
1483
  if (q.aggregations?.some((a) => a.dateGrouping)) throw new Error("Date grouping currently requires local metric execution.");
1340
- if (q.aggregations?.some((a) => a.diagnostics?.length || a.expression !== void 0 || a.expressionY !== void 0 || a.distribution || a.scope === "shownRows" || a.sort?.length || a.groupLimit)) throw new Error("Metric definition requires the version 2 metric transport.");
1484
+ if (q.aggregations?.some((a) => a.diagnostics?.length || a.expression !== void 0 || a.expressionY !== void 0 || a.distribution || a.scope === "shownRows" || a.sort?.length || a.groupLimit || a.where?.length || a.minCount !== void 0)) throw new Error("Metric definition requires the version 2 metric transport.");
1341
1485
  const byName = indexFields(schema);
1342
1486
  const resolveField2 = (name) => resolveFieldName(schema, name) ?? name;
1343
1487
  const where = q.where.filter((term) => termIsPushdown(term, byName, resolveField2)).map(stripSetFilter);
@@ -1830,6 +1974,15 @@ var FORMULA_FUNCTIONS = [
1830
1974
  f("LENGTH", "LENGTH(text)", "Count Unicode code points.", 1, 1, "number", [
1831
1975
  "text"
1832
1976
  ]),
1977
+ f(
1978
+ "URL_ENCODE",
1979
+ "URL_ENCODE(text)",
1980
+ "Percent-encode UTF-8 text for a URL. Only letters, digits and - . _ ~ are kept.",
1981
+ 1,
1982
+ 1,
1983
+ "text",
1984
+ ["text"]
1985
+ ),
1833
1986
  ...["LOWER", "UPPER", "TRIM", "LTRIM", "RTRIM"].map(
1834
1987
  (n) => f(
1835
1988
  n,
@@ -2202,6 +2355,14 @@ function compileFormula(source, fields, resolve, extension) {
2202
2355
  } catch {
2203
2356
  throw new FormulaError("Invalid JSON string literal.", from, pos);
2204
2357
  }
2358
+ } else if (source[pos] === "'") {
2359
+ const end = source.indexOf("'", pos + 1);
2360
+ const text = end < 0 ? "" : source.slice(pos + 1, end);
2361
+ throw new FormulaError(
2362
+ end < 0 || text.length > 40 || /["\\\n]/.test(text) ? 'Text literals use double quotes ("text"), not single quotes.' : `Text literals use double quotes: write "${text}" instead of '${text}'.`,
2363
+ from,
2364
+ end < 0 ? pos + 1 : end + 1
2365
+ );
2205
2366
  } else if (match = source.slice(pos).match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/)) {
2206
2367
  pos += match[0].length;
2207
2368
  tokens.push({ kind: "number", text: match[0], from, to: pos });
@@ -2295,6 +2456,12 @@ function compileFormula(source, fields, resolve, extension) {
2295
2456
  const op = take();
2296
2457
  const p = priority[op.text];
2297
2458
  if (op.text === "IN") {
2459
+ if (peek().text !== "(")
2460
+ throw new FormulaError(
2461
+ 'IN takes a parenthesized list, e.g. [status] IN ("a", "b"). To test array membership, use ARRAY_CONTAINS([tags], "a").',
2462
+ op.from,
2463
+ peek().to
2464
+ );
2298
2465
  expect("(");
2299
2466
  const args = [node];
2300
2467
  do {
@@ -2313,6 +2480,12 @@ function compileFormula(source, fields, resolve, extension) {
2313
2480
  node = call(`op:${op.text}`, [node, right], node.from, right.to);
2314
2481
  }
2315
2482
  }
2483
+ if (peek().text === "NOT" && tokens[i + 1]?.text === "IN")
2484
+ throw new FormulaError(
2485
+ 'NOT IN is not supported. Write NOT ([status] IN ("a", "b")), or combine != comparisons with AND.',
2486
+ peek().from,
2487
+ tokens[i + 1].to
2488
+ );
2316
2489
  depth--;
2317
2490
  return node;
2318
2491
  }
@@ -2376,6 +2549,12 @@ function compileFormula(source, fields, resolve, extension) {
2376
2549
  ts.forEach((_, j) => need(j, "number"));
2377
2550
  return "number";
2378
2551
  }
2552
+ if (op === "IN" && ts.some((t) => t === "textarray"))
2553
+ throw new FormulaError(
2554
+ 'IN compares one value with a list of values. For arrays, use ARRAY_CONTAINS([tags], "a"), or OR several ARRAY_CONTAINS calls.',
2555
+ n.from,
2556
+ n.to
2557
+ );
2379
2558
  same(ts, n);
2380
2559
  return "bool";
2381
2560
  }
@@ -2630,6 +2809,14 @@ function formulaRuntime(ast, inputs, extension) {
2630
2809
  case "LENGTH":
2631
2810
  out = Array.from(str(a)).length;
2632
2811
  break;
2812
+ case "URL_ENCODE":
2813
+ if (!/^(?:[^\ud800-\udfff]|[\ud800-\udbff][\udc00-\udfff])*$/.test(str(a)))
2814
+ fail("Text is not valid Unicode.");
2815
+ out = encodeURIComponent(str(a)).replace(
2816
+ /[!'()*]/g,
2817
+ (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`
2818
+ );
2819
+ break;
2633
2820
  case "LOWER":
2634
2821
  out = str(a).toLowerCase();
2635
2822
  break;
@@ -2907,7 +3094,40 @@ function formulaRuntime(ast, inputs, extension) {
2907
3094
  }
2908
3095
  }
2909
3096
 
3097
+ // src/groupKeys.ts
3098
+ function datetimeValue(value) {
3099
+ const time = value instanceof Date ? value.getTime() : typeof value === "number" ? value : typeof value === "string" && value !== "" ? Date.parse(value) : NaN;
3100
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
3101
+ }
3102
+ function keyClause(field, value) {
3103
+ if (!field || field.type === "textarray" || !isFilterable(field)) return null;
3104
+ if (value == null) return { field: field.name, op: "is_null", value: "" };
3105
+ if (field.type === "datetime") {
3106
+ const iso = datetimeValue(value);
3107
+ return iso === null ? null : { field: field.name, op: "=", value: iso };
3108
+ }
3109
+ if (typeof value === "number" ? !Number.isFinite(value) : typeof value !== "string" && typeof value !== "boolean")
3110
+ return null;
3111
+ return { field: field.name, op: "=", value: String(value) };
3112
+ }
3113
+ function groupKeyTerms(keys, group, schema) {
3114
+ const terms = [];
3115
+ for (const key of keys) {
3116
+ const term = keyClause(resolveField(schema, key), group[key]);
3117
+ if (!term) return null;
3118
+ terms.push(term);
3119
+ }
3120
+ return terms;
3121
+ }
3122
+
2910
3123
  // src/grouping.ts
3124
+ var GROUP_LINK_RENDER = "qt_group_link";
3125
+ function isGroupLinkValue(value) {
3126
+ return value != null && typeof value === "object" && value.kind === "group-link";
3127
+ }
3128
+ function isGroupLinkField(field) {
3129
+ return field?.render === GROUP_LINK_RENDER;
3130
+ }
2911
3131
  var GROUP_ID_FIELD = "__qt_group_id__";
2912
3132
  var MAX_REPRESENTATIVE_SORTS = 8;
2913
3133
  var MAX_SUMMARY_COLUMNS = 64;
@@ -2916,6 +3136,38 @@ function withoutGrouping(query) {
2916
3136
  const { grouping: _grouping, ...result } = query;
2917
3137
  return result;
2918
3138
  }
3139
+ function exitGrouping(query) {
3140
+ return query.grouping ? query.grouping.source : query;
3141
+ }
3142
+ function withGroupingKeys(query, keys) {
3143
+ const before = query.grouping;
3144
+ if (!keys.length) return exitGrouping(query);
3145
+ if (!before)
3146
+ return {
3147
+ select: keys.map((field) => ({ field })),
3148
+ where: [],
3149
+ orderBy: [],
3150
+ limit: query.limit,
3151
+ offset: 0,
3152
+ grouping: { keys, summaries: [], representative: null, source: query }
3153
+ };
3154
+ const removed = new Set(before.keys.filter((key) => !keys.includes(key)));
3155
+ const columns = query.select.length ? query.select : before.keys.map((field) => ({ field }));
3156
+ return {
3157
+ ...query,
3158
+ grouping: { ...before, keys },
3159
+ offset: 0,
3160
+ aggregations: [],
3161
+ select: [
3162
+ ...keys.filter((key) => !before.keys.includes(key)).map((field) => ({ field })),
3163
+ ...columns.filter((c) => !removed.has(c.field))
3164
+ ],
3165
+ where: query.where.filter(
3166
+ (term) => !predicatesOf(term).some((p) => removed.has(p.field))
3167
+ ),
3168
+ orderBy: query.orderBy.filter((term) => !removed.has(term.field))
3169
+ };
3170
+ }
2919
3171
  var OP_LABEL = {
2920
3172
  count: "COUNT",
2921
3173
  count_distinct: "DISTINCT",
@@ -2924,7 +3176,8 @@ var OP_LABEL = {
2924
3176
  min: "MIN",
2925
3177
  max: "MAX",
2926
3178
  row: "ROW",
2927
- formula: "\u0192x"
3179
+ formula: "\u0192x",
3180
+ link: "LINK"
2928
3181
  };
2929
3182
  function createGroupingModel(inputSchema) {
2930
3183
  const sourceField = (name) => inputSchema.fields.find((f2) => f2.name === name);
@@ -2949,6 +3202,7 @@ function createGroupingModel(inputSchema) {
2949
3202
  }
2950
3203
  function summaryExpression(column) {
2951
3204
  if (column.op === "formula") return column.expression ?? "";
3205
+ if (column.op === "link") return "";
2952
3206
  const field = column.field ? `[${column.field.replace(/\]/g, "]]")}]` : "";
2953
3207
  return `${column.op === "row" ? "COUNT" : column.op.toUpperCase()}(${field})`;
2954
3208
  }
@@ -2970,6 +3224,8 @@ function createGroupingModel(inputSchema) {
2970
3224
  function summaryLabel(column) {
2971
3225
  if (column.op === "formula")
2972
3226
  return column.label?.trim() || "Custom aggregate";
3227
+ if (column.op === "link")
3228
+ return column.label?.trim() || typeof column.link?.label === "string" && column.link.label.trim() || "Link";
2973
3229
  if (!column.field) return "Row count";
2974
3230
  const label = sourceField(column.field)?.label ?? column.field;
2975
3231
  const prefix = {
@@ -2980,10 +3236,70 @@ function createGroupingModel(inputSchema) {
2980
3236
  min: "Min",
2981
3237
  max: "Max",
2982
3238
  row: "Representative \xB7",
2983
- formula: ""
3239
+ formula: "",
3240
+ link: ""
2984
3241
  };
2985
3242
  return `${prefix[column.op]} ${label}`;
2986
3243
  }
3244
+ function linkReferences(column, keys) {
3245
+ const link = column.link;
3246
+ if (!link) return [];
3247
+ const names = /* @__PURE__ */ new Set();
3248
+ if (typeof link.label === "object") names.add(link.label.column);
3249
+ if (link.to.kind === "template")
3250
+ for (const name of templateFields(link.to.template)) names.add(name);
3251
+ else if (link.to.keyFilters !== false)
3252
+ for (const key of keys) names.add(key);
3253
+ return [...names];
3254
+ }
3255
+ function linkProblem(column, keys, summaries) {
3256
+ const link = column.link;
3257
+ if (column.op !== "link") return null;
3258
+ if (!link) return "Invalid link definition.";
3259
+ const readable = (name) => keys.includes(name) || summaries.some((other) => other.id === name && other.op !== "link");
3260
+ if (typeof link.label === "object" && !readable(link.label.column))
3261
+ return `Unknown link label column: ${link.label.column}.`;
3262
+ if (link.to.kind === "template") {
3263
+ if (!link.to.template.trim()) return "Enter a link URL template.";
3264
+ return urlTemplateProblem(link.to.template, [], readable);
3265
+ }
3266
+ if (link.to.keyFilters !== false)
3267
+ for (const key of keys) {
3268
+ const field = sourceField(key);
3269
+ if (field?.type === "textarray")
3270
+ return `Key filters cannot match the textarray key ${field.label}.`;
3271
+ if (!field || !isFilterable(field))
3272
+ return `Key filters cannot match the key ${field?.label ?? key}.`;
3273
+ }
3274
+ return null;
3275
+ }
3276
+ function linkDiagnostics(definition) {
3277
+ return definition.summaries.flatMap((column) => {
3278
+ const problem = linkProblem(column, definition.keys, definition.summaries);
3279
+ return problem ? [`${summaryLabel(column)}: ${problem}`] : [];
3280
+ });
3281
+ }
3282
+ function linkValue(column, problem, keys, schema, row) {
3283
+ const link = column.link;
3284
+ const read = (name) => {
3285
+ const field = schema.fields.find((f2) => f2.name === name);
3286
+ const value2 = field ? readFieldValue(field, row) : void 0;
3287
+ if (value2 == null || isComputedCellError(value2)) return null;
3288
+ if (field?.type === "datetime" || value2 instanceof Date)
3289
+ return datetimeValue(value2) ?? value2;
3290
+ return Array.isArray(value2) ? value2.join(", ") : value2;
3291
+ };
3292
+ const label = !link ? summaryLabel(column) : typeof link.label === "string" ? link.label : String(read(link.label.column) ?? "");
3293
+ const value = { kind: "group-link", label, to: link?.to ?? null };
3294
+ if (problem || !link) return { ...value, problem: problem ?? "Invalid link definition." };
3295
+ if (link.to.kind === "template") {
3296
+ const href = fillUrlTemplate(link.to.template, [], (token) => read(token.slice(6)));
3297
+ return href === null ? { ...value, problem: "A value in this link is empty." } : { ...value, href };
3298
+ }
3299
+ if (link.to.keyFilters === false) return value;
3300
+ const terms = groupKeyTerms(keys, row, inputSchema);
3301
+ return terms ? { ...value, terms } : { ...value, problem: "This group cannot be matched exactly." };
3302
+ }
2987
3303
  function resultSchema(keys, summaries) {
2988
3304
  const fields = [
2989
3305
  {
@@ -3014,7 +3330,28 @@ function createGroupingModel(inputSchema) {
3014
3330
  }];
3015
3331
  })
3016
3332
  ];
3333
+ let schema;
3017
3334
  for (const column of summaries) {
3335
+ if (column.op === "link") {
3336
+ const problem = linkProblem(column, keys, summaries);
3337
+ fields.push({
3338
+ name: column.id,
3339
+ label: summaryLabel(column),
3340
+ type: "text",
3341
+ source: {
3342
+ kind: "derived",
3343
+ dependencies: linkReferences(column, keys),
3344
+ accessor: (row) => linkValue(column, problem, keys, schema, row)
3345
+ },
3346
+ select: { align: "left" },
3347
+ filter: { enabled: false },
3348
+ sort: { enabled: false },
3349
+ aggregate: { measure: false, groupable: false },
3350
+ render: GROUP_LINK_RENDER,
3351
+ group: "Links"
3352
+ });
3353
+ continue;
3354
+ }
3018
3355
  if (column.op === "formula") {
3019
3356
  let type = "number";
3020
3357
  try {
@@ -3031,7 +3368,8 @@ function createGroupingModel(inputSchema) {
3031
3368
  type,
3032
3369
  source: { kind: "backend" },
3033
3370
  group: "Aggregates",
3034
- select: { align: type === "number" ? "right" : "left" }
3371
+ select: { align: type === "number" ? "right" : "left" },
3372
+ ...column.display ? { display: column.display } : {}
3035
3373
  });
3036
3374
  continue;
3037
3375
  }
@@ -3049,17 +3387,21 @@ function createGroupingModel(inputSchema) {
3049
3387
  },
3050
3388
  group: example ? "Representative row" : "Summaries",
3051
3389
  ...!numeric && input?.aggregate?.groupable !== void 0 ? { aggregate: { groupable: input.aggregate.groupable } } : {},
3052
- ...typeof input?.render === "string" && !counting ? { render: input.render } : {},
3053
- ...input?.display && !counting ? { display: input.display } : {}
3390
+ // An explicit summary format replaces the inherited source presentation.
3391
+ ...column.display ? { display: column.display } : {
3392
+ ...typeof input?.render === "string" && !counting ? { render: input.render } : {},
3393
+ ...input?.display && !counting ? { display: input.display } : {}
3394
+ }
3054
3395
  });
3055
3396
  }
3056
- return {
3397
+ schema = {
3057
3398
  name: `${inputSchema.name}:groups`,
3058
3399
  idField: GROUP_ID_FIELD,
3059
3400
  fields,
3060
3401
  defaultSelect: keys.filter((key) => sourceField(key)).map((field) => ({ field })),
3061
3402
  defaultLimit: 25
3062
3403
  };
3404
+ return schema;
3063
3405
  }
3064
3406
  function keyValue(value) {
3065
3407
  if (value == null) return null;
@@ -3160,7 +3502,7 @@ function createGroupingModel(inputSchema) {
3160
3502
  }
3161
3503
  }
3162
3504
  const formulas = new Map(
3163
- summaries.filter((c) => c.op !== "row").map((column) => {
3505
+ summaries.filter((c) => c.op !== "row" && c.op !== "link").map((column) => {
3164
3506
  try {
3165
3507
  const problem = summaryProblem(column);
3166
3508
  if (problem) throw new Error(problem);
@@ -3194,6 +3536,7 @@ function createGroupingModel(inputSchema) {
3194
3536
  );
3195
3537
  const chosen = rule && summaries.some((column) => column.op === "row") ? representative(members, rule, schema) : null;
3196
3538
  for (const column of summaries) {
3539
+ if (column.op === "link") continue;
3197
3540
  if (column.op !== "row") {
3198
3541
  const compiled = formulas.get(column.id);
3199
3542
  if (!compiled.plan) {
@@ -3301,12 +3644,22 @@ function createGroupingModel(inputSchema) {
3301
3644
  GROUP_ID_FIELD,
3302
3645
  ...inputSchema.fields.map((field) => field.name)
3303
3646
  ]);
3647
+ const sourceNames = new Set(inputSchema.fields.map((field) => field.name));
3304
3648
  for (const column of definition.summaries) {
3649
+ if (sourceNames.has(column.id))
3650
+ throw new Error(
3651
+ `Aggregate column ID "${column.id}" is already a source field name. Choose a different id, such as "${column.op === "formula" || column.op === "row" ? "summary" : column.op}_${column.id}".`
3652
+ );
3653
+ if (ids.has(column.id) && column.id !== GROUP_ID_FIELD)
3654
+ throw new Error(
3655
+ `Aggregate column ID "${column.id}" is used by more than one column.`
3656
+ );
3305
3657
  if (!column.id || column.id.length > 256 || ids.has(column.id) || isComputedField(column.id) || ["__proto__", "constructor", "prototype"].includes(column.id))
3306
3658
  throw new Error(
3307
- "Aggregate column IDs must be unique and distinct from source fields."
3659
+ "Aggregate column IDs must be 1\u2013256 characters and cannot be reserved names or @computed/ references."
3308
3660
  );
3309
3661
  ids.add(column.id);
3662
+ if (column.op === "link") continue;
3310
3663
  if (column.op === "formula") {
3311
3664
  const error = summaryProblem(column);
3312
3665
  if (error) throw new Error(error);
@@ -3330,6 +3683,8 @@ function createGroupingModel(inputSchema) {
3330
3683
  summaryExpression,
3331
3684
  summaryProblem,
3332
3685
  summaryLabel,
3686
+ linkProblem,
3687
+ linkDiagnostics,
3333
3688
  resultSchema,
3334
3689
  groupRows,
3335
3690
  filteredInput,
@@ -3707,10 +4062,15 @@ function metricDateRange(config, query, observed, now, canonical) {
3707
4062
  if (term.op === "<" || term.op === "<=" || term.op === "=")
3708
4063
  end = Math.min(end, time - (term.op === "<" ? 1 : 0));
3709
4064
  }
3710
- if (!Number.isFinite(start) || !Number.isFinite(end))
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 ");
3711
4070
  throw Error(
3712
- "Include empty dates needs a bounded date filter. Choose observed dates or a custom range."
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.`
3713
4072
  );
4073
+ }
3714
4074
  if (end < start) return void 0;
3715
4075
  return [
3716
4076
  metricDateFloor(start, config.interval, config.timeZone),
@@ -4039,6 +4399,15 @@ function validateMetric(c, schema, resolve) {
4039
4399
  }
4040
4400
  if (c.groupLimit !== void 0 && (!Number.isInteger(c.groupLimit) || c.groupLimit < 1 || c.groupLimit > 1e4))
4041
4401
  throw new FormulaError("Group limit must be 1\u201310,000.");
4402
+ if (c.minCount !== void 0) {
4403
+ if (!Number.isInteger(c.minCount) || c.minCount < 1 || c.minCount > 1e6)
4404
+ throw new FormulaError("Minimum rows per group must be 1\u20131,000,000.");
4405
+ if (!c.groupBy.length)
4406
+ throw new FormulaError(
4407
+ "Minimum rows per group requires at least one grouping field."
4408
+ );
4409
+ }
4410
+ validateMetricWhere(c.where, schema);
4042
4411
  for (const s of c.sort ?? []) {
4043
4412
  if (s.key === "y" && c.display?.kind !== "scatter")
4044
4413
  throw new FormulaError("Y sorting requires a scatter metric.");
@@ -4063,6 +4432,35 @@ function validateMetric(c, schema, resolve) {
4063
4432
  ];
4064
4433
  }
4065
4434
  }
4435
+ function validateMetricWhere(where, schema) {
4436
+ if (where === void 0) return;
4437
+ if (!Array.isArray(where)) throw new FormulaError("Invalid metric filter.");
4438
+ const predicates = where.flatMap((term) => predicatesOf(term));
4439
+ if (predicates.length > MAX_WHERE_CLAUSES)
4440
+ throw new FormulaError(
4441
+ `A metric filter allows at most ${MAX_WHERE_CLAUSES} predicates.`
4442
+ );
4443
+ const fields = indexFields(schema);
4444
+ for (const p of predicates) {
4445
+ const name = resolveFieldName(schema, p.field) ?? p.field;
4446
+ const field = fields.get(name);
4447
+ if (!field || !isFilterable(field) || isComputedField(name) && !computedInputAllowed(schema, name, "filter"))
4448
+ throw new FormulaError(`Field ${p.field} cannot filter metrics.`);
4449
+ if (p.op === "matches_regex" || p.op === "not_matches_regex")
4450
+ throw new FormulaError("Regex is unavailable in metric filters.");
4451
+ if (!opsForField(field).includes(p.op))
4452
+ throw new FormulaError(
4453
+ `Field ${p.field} does not support the ${p.op} filter.`
4454
+ );
4455
+ }
4456
+ }
4457
+ function metricPopulationError(clause, support) {
4458
+ if (clause.where?.length && !support.filters)
4459
+ return "This server does not support metric filters.";
4460
+ if (clause.minCount !== void 0 && !support.minCount)
4461
+ return "This server does not support a minimum rows per group.";
4462
+ return void 0;
4463
+ }
4066
4464
  function metricDependencies(c, schema, resolve) {
4067
4465
  return [
4068
4466
  .../* @__PURE__ */ new Set([
@@ -4084,6 +4482,8 @@ function metricComputationKey(clauses) {
4084
4482
  dateGrouping: c.dateGrouping,
4085
4483
  sort: c.sort,
4086
4484
  groupLimit: c.groupLimit,
4485
+ where: c.where,
4486
+ minCount: c.minCount,
4087
4487
  distribution: distributionActive(c) ? c.distribution : void 0
4088
4488
  })).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
4089
4489
  );
@@ -4119,6 +4519,12 @@ function toMetricQuery(q, schema, execution) {
4119
4519
  const raw = q;
4120
4520
  q = normalizeQueryState(q);
4121
4521
  const server = toServerQuery(q, schema);
4522
+ const metricWhere = /* @__PURE__ */ new Map();
4523
+ for (const c of q.aggregations ?? [])
4524
+ if (c.where?.length) {
4525
+ const pushed = toServerQuery({ ...EMPTY_QUERY, where: c.where }, schema).where;
4526
+ metricWhere.set(c.id, pushed);
4527
+ }
4122
4528
  const metrics = (q.aggregations ?? []).map((c) => {
4123
4529
  const {
4124
4530
  label,
@@ -4128,10 +4534,12 @@ function toMetricQuery(q, schema, execution) {
4128
4534
  expression,
4129
4535
  expressionY,
4130
4536
  dateGrouping,
4537
+ where,
4131
4538
  ...rest
4132
4539
  } = c;
4133
4540
  return {
4134
4541
  ...rest,
4542
+ ...where?.length ? { where: metricWhere.get(c.id) ?? [] } : {},
4135
4543
  ...!distributionActive(c) && expression !== void 0 ? { expression } : {},
4136
4544
  ...display?.kind === "scatter" ? {
4137
4545
  display: { kind: "scatter" },
@@ -4147,6 +4555,15 @@ function toMetricQuery(q, schema, execution) {
4147
4555
  code: "invalid_metric"
4148
4556
  }))
4149
4557
  );
4558
+ for (const c of q.aggregations ?? [])
4559
+ if (c.where?.length && metricWhere.get(c.id)?.length !== c.where.length)
4560
+ diagnostics.push({
4561
+ metricId: c.id,
4562
+ code: "residual_metric_filter",
4563
+ from: 0,
4564
+ to: 1,
4565
+ message: "Metric filters must use server-filterable fields."
4566
+ });
4150
4567
  for (const c of q.aggregations ?? [])
4151
4568
  if (c.dateGrouping)
4152
4569
  diagnostics.push({
@@ -4401,6 +4818,18 @@ function evaluateMetrics(rows, q, schema, options2 = {}) {
4401
4818
  }
4402
4819
  if (inputRows.length > (options2.maxRows ?? 1e6))
4403
4820
  throw Error("Metric row budget exceeded.");
4821
+ const population = inputRows.length;
4822
+ if (c.where?.length)
4823
+ inputRows = applyQuery(
4824
+ inputRows,
4825
+ {
4826
+ ...EMPTY_QUERY,
4827
+ where: c.where,
4828
+ limit: Number.MAX_SAFE_INTEGER
4829
+ },
4830
+ schema,
4831
+ { now }
4832
+ ).rows;
4404
4833
  const groups = /* @__PURE__ */ new Map();
4405
4834
  const keyLimit = options2.maxGroupKeyLength ?? 65536, byteLimit = options2.maxGroupBytes ?? 16e6;
4406
4835
  if (![keyLimit, byteLimit].every((v) => Number.isSafeInteger(v) && v >= 0))
@@ -4663,6 +5092,14 @@ function evaluateMetrics(rows, q, schema, options2 = {}) {
4663
5092
  }
4664
5093
  }
4665
5094
  }
5095
+ let hiddenGroups;
5096
+ if (c.minCount !== void 0) {
5097
+ const kept = buckets.filter(
5098
+ (b) => b.count === 0 || b.count >= c.minCount
5099
+ );
5100
+ hiddenGroups = buckets.length - kept.length;
5101
+ buckets = kept;
5102
+ }
4666
5103
  const ordering = c.sort?.length ? c.sort : c.dateGrouping ? [{ key: "group0", dir: "asc", nulls: "last" }] : [{ key: "value", dir: "desc", nulls: "last" }];
4667
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;
4668
5105
  buckets.sort((a, b) => {
@@ -4701,9 +5138,10 @@ function evaluateMetrics(rows, q, schema, options2 = {}) {
4701
5138
  id: c.id,
4702
5139
  buckets,
4703
5140
  scope: c.scope ?? "allMatching",
4704
- processedRows: inputRows.length,
5141
+ processedRows: population,
4705
5142
  groupCount,
4706
5143
  coverage: "exact",
5144
+ ...hiddenGroups !== void 0 ? { hiddenGroups } : {},
4707
5145
  ...other ? { other } : {}
4708
5146
  };
4709
5147
  } catch (e) {
@@ -4785,7 +5223,7 @@ function applyAggregations(rows, q, schema, options2 = {}) {
4785
5223
  ))
4786
5224
  return evaluateMetrics(rows, q, schema, options2);
4787
5225
  const advanced = (q.aggregations ?? []).filter(
4788
- (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
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
4789
5227
  );
4790
5228
  if (advanced.length) {
4791
5229
  if ((q.aggregations?.length ?? 0) > 20 || normalizeQueryState(q).aggregations?.length !== q.aggregations?.length)
@@ -5390,6 +5828,7 @@ function sourceDependencies(query, schema, resolveComputed) {
5390
5828
  model.validate(query.grouping);
5391
5829
  for (const key of query.grouping.keys) fields.add(key);
5392
5830
  for (const summary of query.grouping.summaries) {
5831
+ if (summary.op === "link") continue;
5393
5832
  if (summary.op === "row") fields.add(summary.field);
5394
5833
  else for (const dependency of compileMetric(model.summaryExpression(summary), schema, resolveComputed).dependencies) fields.add(dependency);
5395
5834
  }
@@ -5424,6 +5863,242 @@ function sourceDependencies(query, schema, resolveComputed) {
5424
5863
  return { fields: [...fields].sort(), baseFields: [...base].sort(), identity: schema.idField, paths };
5425
5864
  }
5426
5865
 
5866
+ // src/queryPatch.ts
5867
+ function clauseKey(clause) {
5868
+ return JSON.stringify([
5869
+ clause.field,
5870
+ clause.op,
5871
+ NULLARY_OPS.has(clause.op) ? "" : clause.value,
5872
+ clause.negated === true
5873
+ ]);
5874
+ }
5875
+ function whereTermKey(term) {
5876
+ if (!isOrGroup(term)) return clauseKey(term);
5877
+ return `any:${JSON.stringify(term.any.map(clauseKey).sort())}`;
5878
+ }
5879
+ function appendWhereTerms(where, terms) {
5880
+ const present = new Set(where.map(whereTermKey));
5881
+ const out = [...where];
5882
+ for (const term of terms) {
5883
+ const key = whereTermKey(term);
5884
+ if (present.has(key)) continue;
5885
+ present.add(key);
5886
+ out.push(term);
5887
+ }
5888
+ return out;
5889
+ }
5890
+ function filterUnits(where) {
5891
+ const units = [];
5892
+ for (const term of where) {
5893
+ const last = units[units.length - 1];
5894
+ const previous = last?.[last.length - 1];
5895
+ if (last && previous && !isOrGroup(term) && !isOrGroup(previous) && term.op === "!=" && previous.op === "!=" && term.field === previous.field)
5896
+ last.push(term);
5897
+ else units.push([term]);
5898
+ }
5899
+ return units;
5900
+ }
5901
+ function unitPresent(unit, present) {
5902
+ return unit.every((term) => present.has(whereTermKey(term)));
5903
+ }
5904
+ function removeUnits(where, units) {
5905
+ const present = new Set(where.map(whereTermKey));
5906
+ const remove = new Set(
5907
+ units.filter((unit) => unitPresent(unit, present)).flatMap((unit) => unit.map(whereTermKey))
5908
+ );
5909
+ return where.filter((term) => !remove.has(whereTermKey(term)));
5910
+ }
5911
+ function sameTermSet(a, b) {
5912
+ const keys = (where) => JSON.stringify(where.map(whereTermKey).sort());
5913
+ return keys(a) === keys(b);
5914
+ }
5915
+ function patchParts(patch) {
5916
+ const raw = patch.query ?? {};
5917
+ const normalized = normalizeQueryState(raw);
5918
+ const stage = normalized.grouping ? "groups" : "records";
5919
+ const filters = normalized.grouping ? normalized.grouping.source.where : normalized.where;
5920
+ const owns = new Set(patch.owns);
5921
+ if (typeof raw.limit !== "number") owns.delete("limit");
5922
+ return {
5923
+ owns,
5924
+ stage,
5925
+ filters,
5926
+ units: filterUnits(filters),
5927
+ grouping: normalized.grouping ? {
5928
+ keys: normalized.grouping.keys,
5929
+ summaries: normalized.grouping.summaries,
5930
+ representative: normalized.grouping.representative,
5931
+ having: normalized.where
5932
+ } : null,
5933
+ select: normalized.select,
5934
+ orderBy: normalized.orderBy,
5935
+ limit: typeof raw.limit === "number" ? normalized.limit : void 0,
5936
+ aggregations: normalized.aggregations ?? []
5937
+ };
5938
+ }
5939
+ var recordStage = (query) => query.grouping?.source ?? query;
5940
+ function withRecordStage(query, next) {
5941
+ return query.grouping ? { ...query, grouping: { ...query.grouping, source: next } } : next;
5942
+ }
5943
+ function stageOf(query, stage) {
5944
+ if (stage === "records") return recordStage(query);
5945
+ return query.grouping ? query : void 0;
5946
+ }
5947
+ function withStage(query, stage, update) {
5948
+ if (stage === "records") return withRecordStage(query, update(recordStage(query)));
5949
+ return query.grouping ? update(query) : query;
5950
+ }
5951
+ function withAggregations(query, aggregations) {
5952
+ const { aggregations: _previous, ...rest } = query;
5953
+ return aggregations.length ? { ...rest, aggregations } : rest;
5954
+ }
5955
+ var clone2 = (value) => structuredClone(value);
5956
+ var summaryMeaning = ({ id, op, field, expression, link }) => JSON.stringify(op === "link" ? [id, op, link ?? null] : [id, op, field, expression]);
5957
+ function summarySignature(summaries) {
5958
+ return JSON.stringify(summaries.map(summaryMeaning));
5959
+ }
5960
+ function canonicalJson(value) {
5961
+ return JSON.stringify(
5962
+ value,
5963
+ (_key, item) => item && typeof item === "object" && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : item
5964
+ );
5965
+ }
5966
+ function metricSignature(metric) {
5967
+ const { id: _id, label: _label, display: _display, layout: _layout, ...rest } = metric;
5968
+ return canonicalJson(rest);
5969
+ }
5970
+ function facetMatches(query, parts, facet) {
5971
+ switch (facet) {
5972
+ case "filters": {
5973
+ const present = new Set(recordStage(query).where.map(whereTermKey));
5974
+ return parts.units.every((unit) => unitPresent(unit, present));
5975
+ }
5976
+ case "grouping": {
5977
+ const grouping = query.grouping;
5978
+ if (!parts.grouping || !grouping) return !parts.grouping && !grouping;
5979
+ return JSON.stringify(grouping.keys) === JSON.stringify(parts.grouping.keys) && summarySignature(grouping.summaries) === summarySignature(parts.grouping.summaries) && JSON.stringify(grouping.representative) === JSON.stringify(parts.grouping.representative) && sameTermSet(query.where, parts.grouping.having);
5980
+ }
5981
+ case "columns": {
5982
+ const stage = stageOf(query, parts.stage);
5983
+ return !!stage && JSON.stringify(stage.select.map((c) => c.field)) === JSON.stringify(parts.select.map((c) => c.field));
5984
+ }
5985
+ case "order": {
5986
+ const stage = stageOf(query, parts.stage);
5987
+ return !!stage && JSON.stringify(stage.orderBy) === JSON.stringify(parts.orderBy);
5988
+ }
5989
+ case "limit": {
5990
+ const stage = stageOf(query, parts.stage);
5991
+ return !!stage && stage.limit === parts.limit;
5992
+ }
5993
+ case "metrics": {
5994
+ const stage = stageOf(query, parts.stage);
5995
+ const signatures = (metrics) => JSON.stringify(metrics.map(metricSignature).sort());
5996
+ return !!stage && signatures(stage.aggregations ?? []) === signatures(parts.aggregations);
5997
+ }
5998
+ }
5999
+ }
6000
+ function matchPatch(query, patch) {
6001
+ const normalized = normalizeQueryState(query);
6002
+ const parts = patchParts(patch);
6003
+ const owned = [...parts.owns];
6004
+ const modified = owned.filter((facet) => !facetMatches(normalized, parts, facet));
6005
+ if (!modified.length) return { status: "active", modified: [] };
6006
+ const present = new Set(recordStage(normalized).where.map(whereTermKey));
6007
+ const recognizable = owned.some((facet) => !modified.includes(facet) && (facet !== "filters" || parts.units.length > 0)) || parts.owns.has("filters") && parts.units.some((unit) => unitPresent(unit, present));
6008
+ return recognizable ? { status: "modified", modified } : { status: "inactive", modified: [] };
6009
+ }
6010
+ function resetOffsets(query) {
6011
+ return query.grouping ? { ...query, offset: 0, grouping: { ...query.grouping, source: { ...query.grouping.source, offset: 0 } } } : { ...query, offset: 0 };
6012
+ }
6013
+ function applyGrouping(query, grouping) {
6014
+ if (!grouping) return exitGrouping(query);
6015
+ const before = query.grouping;
6016
+ const keyed = before && JSON.stringify(before.keys) === JSON.stringify(grouping.keys) ? query : withGroupingKeys(query, grouping.keys);
6017
+ const previous = keyed.grouping;
6018
+ const summaries = clone2(grouping.summaries);
6019
+ const kept = new Set(summaries.map(summaryMeaning));
6020
+ const changed = new Set(previous.summaries.filter((column) => !kept.has(summaryMeaning(column))).map((column) => column.id));
6021
+ const next = {
6022
+ ...keyed,
6023
+ grouping: { ...previous, summaries, representative: clone2(grouping.representative) },
6024
+ where: clone2(grouping.having),
6025
+ select: keyed.select.filter((c) => !changed.has(c.field)),
6026
+ orderBy: keyed.orderBy.filter((term) => !changed.has(term.field))
6027
+ };
6028
+ return changed.size ? withAggregations(next, []) : next;
6029
+ }
6030
+ function removePatch(query, patch) {
6031
+ const parts = patchParts(patch);
6032
+ const matches = (facet) => parts.owns.has(facet) && facetMatches(normalizeQueryState(query), parts, facet);
6033
+ const reset = {
6034
+ columns: matches("columns"),
6035
+ order: matches("order"),
6036
+ metrics: matches("metrics"),
6037
+ grouping: matches("grouping")
6038
+ };
6039
+ let next = query;
6040
+ if (parts.owns.has("filters")) {
6041
+ const records = recordStage(next);
6042
+ next = withRecordStage(next, { ...records, where: removeUnits(records.where, parts.units) });
6043
+ }
6044
+ next = withStage(next, parts.stage, (stage) => {
6045
+ let out = stage;
6046
+ if (reset.columns) out = { ...out, select: [] };
6047
+ if (reset.order) out = { ...out, orderBy: [] };
6048
+ if (reset.metrics) out = withAggregations(out, []);
6049
+ return out;
6050
+ });
6051
+ if (reset.grouping) next = exitGrouping(next);
6052
+ return resetOffsets(next);
6053
+ }
6054
+ function applyPatch(query, patch, options2 = {}) {
6055
+ let next = options2.replacing ? removePatch(query, options2.replacing) : query;
6056
+ const parts = patchParts(patch);
6057
+ if (parts.owns.has("grouping")) next = applyGrouping(next, parts.grouping);
6058
+ if (parts.owns.has("filters")) {
6059
+ const records = recordStage(next);
6060
+ next = withRecordStage(next, { ...records, where: appendWhereTerms(records.where, clone2(parts.filters)) });
6061
+ }
6062
+ next = withStage(next, parts.stage, (stage) => {
6063
+ let out = stage;
6064
+ if (parts.owns.has("columns")) out = { ...out, select: clone2(parts.select) };
6065
+ if (parts.owns.has("order")) out = { ...out, orderBy: clone2(parts.orderBy) };
6066
+ if (parts.owns.has("limit")) out = { ...out, limit: parts.limit ?? out.limit };
6067
+ if (parts.owns.has("metrics")) out = withAggregations(out, clone2(parts.aggregations));
6068
+ return out;
6069
+ });
6070
+ return resetOffsets(next);
6071
+ }
6072
+
6073
+ // src/groupLinks.ts
6074
+ function withoutLinkSummaries(summaries) {
6075
+ return summaries.filter((column) => column.op !== "link");
6076
+ }
6077
+ function groupLinkQuery(query, value) {
6078
+ const to = value.to;
6079
+ if (!query.grouping || to?.kind !== "query" || value.problem) return null;
6080
+ const base = to.base === "empty" ? structuredClone(EMPTY_QUERY) : exitGrouping(query);
6081
+ if (to.keyFilters !== false && !value.terms) return null;
6082
+ const filtered = { ...base, where: appendWhereTerms(base.where, value.terms ?? []), offset: 0 };
6083
+ return to.patch ? applyPatch(filtered, to.patch) : filtered;
6084
+ }
6085
+ function resolveGroupLink(query, value, targets = {}) {
6086
+ const unresolved = (problem) => ({ label: value.label, href: null, problem });
6087
+ if (value.problem) return unresolved(value.problem);
6088
+ if (value.to?.kind === "template") return value.href ? { label: value.label, href: value.href } : unresolved("Invalid link.");
6089
+ if (value.to?.kind !== "query") return unresolved("Invalid link definition.");
6090
+ const route = Object.prototype.hasOwnProperty.call(targets, value.to.target) ? targets[value.to.target] : void 0;
6091
+ if (typeof route !== "function") return unresolved(`Link target "${value.to.target}" is not registered.`);
6092
+ const destination = groupLinkQuery(query, value);
6093
+ if (!destination) return unresolved("This group cannot be matched exactly.");
6094
+ try {
6095
+ const href = route(destination, { ...value.to.params });
6096
+ return typeof href === "string" && href.trim() ? { label: value.label, href } : unresolved(`Link target "${value.to.target}" returned no URL.`);
6097
+ } catch (error) {
6098
+ return unresolved(`Link target "${value.to.target}" failed: ${error instanceof Error ? error.message : String(error)}`);
6099
+ }
6100
+ }
6101
+
5427
6102
  // src/queryV3.ts
5428
6103
  function sourceExecutionSchema(schema, execution, catalogue = []) {
5429
6104
  const fields = new Map(schema.fields.map((field) => [field.name, field]));
@@ -5473,7 +6148,9 @@ function toQueryRequestV3(query, baseSchema, capabilities, execution, options2 =
5473
6148
  for (const term of q.grouping.source.where) for (const predicate of predicatesOf(term))
5474
6149
  if (predicate.field.startsWith("@computed/") && !capabilities.computedSourceFilters) diagnostics.push("This server does not support computed source predicates.");
5475
6150
  }
5476
- const metrics = toMetricQuery(withoutGrouping(q), resultSchema, execution).metrics.map((metric) => {
6151
+ const metricQuery = toMetricQuery(withoutGrouping(q), resultSchema, execution);
6152
+ diagnostics.push(...metricQuery.diagnostics.filter((issue) => issue.code === "residual_metric_filter").map((issue) => issue.message));
6153
+ const metrics = metricQuery.metrics.map((metric) => {
5477
6154
  const dateGrouping = q.aggregations?.find((clause) => clause.id === metric.id)?.dateGrouping;
5478
6155
  return dateGrouping ? { ...metric, dateGrouping } : metric;
5479
6156
  });
@@ -5497,7 +6174,9 @@ function toQueryRequestV3(query, baseSchema, capabilities, execution, options2 =
5497
6174
  result,
5498
6175
  ...q.grouping ? { grouping: {
5499
6176
  keys: q.grouping.keys,
5500
- summaries: q.grouping.summaries,
6177
+ // Summary display is presentation only and link columns are computed on
6178
+ // the client; the wire contract is unchanged.
6179
+ summaries: withoutLinkSummaries(q.grouping.summaries).map(({ display: _display, ...summary }) => summary),
5501
6180
  representative: q.grouping.representative,
5502
6181
  ...q.grouping.diagnostics ? { diagnostics: q.grouping.diagnostics } : {}
5503
6182
  } } : {},
@@ -5520,6 +6199,68 @@ function validateQueryResultV3(result, request, schema) {
5520
6199
  seen.add(id);
5521
6200
  }
5522
6201
  }
6202
+
6203
+ // src/drilldown.ts
6204
+ function metricSchema(query, schema) {
6205
+ if (!query.grouping || schema.idField === GROUP_ID_FIELD) return schema;
6206
+ return createGroupingModel(schema).resultSchema(query.grouping.keys, query.grouping.summaries);
6207
+ }
6208
+ function distributionActive2(clause) {
6209
+ return !!clause.distribution && (!clause.display || clause.display.kind === "box" || clause.display.kind === "histogram");
6210
+ }
6211
+ function inspectBucketQuery(query, bucket, clause, schema) {
6212
+ if (clause.scope === "shownRows" || bucket.keys.length !== clause.groupBy.length) return null;
6213
+ const fields = metricSchema(query, schema);
6214
+ const terms = [...clause.where ?? []];
6215
+ for (const [index, name] of clause.groupBy.entries()) {
6216
+ const field = resolveField(fields, name);
6217
+ const key = bucket.keys[index] ?? null;
6218
+ if (clause.dateGrouping && index === 0 && key !== null) {
6219
+ if (!field || field.type !== "datetime" || !isFilterable(field)) return null;
6220
+ let start;
6221
+ try {
6222
+ start = metricDateTime(key);
6223
+ } catch {
6224
+ return null;
6225
+ }
6226
+ const { interval, timeZone } = clause.dateGrouping;
6227
+ 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
+ );
6232
+ continue;
6233
+ }
6234
+ const term = keyClause(field, key);
6235
+ if (!term) return null;
6236
+ terms.push(term);
6237
+ }
6238
+ if (bucket.bin) {
6239
+ const { min, max, inclusiveMax } = bucket.bin;
6240
+ if (!distributionActive2(clause) || clause.distribution.kind !== "histogram" || !Number.isFinite(min) || !Number.isFinite(max) || max < min)
6241
+ return null;
6242
+ let input;
6243
+ try {
6244
+ input = compileFormula(clause.distribution.input, fields.fields).ast;
6245
+ } catch {
6246
+ return null;
6247
+ }
6248
+ const field = input.kind === "field" ? resolveField(fields, input.name) : void 0;
6249
+ if (!field || field.type !== "number" || !isFilterable(field)) return null;
6250
+ terms.push(
6251
+ { field: field.name, op: ">=", value: String(min) },
6252
+ { field: field.name, op: inclusiveMax ? "<=" : "<", value: String(max) }
6253
+ );
6254
+ }
6255
+ return { ...query, where: appendWhereTerms(query.where, terms), offset: 0 };
6256
+ }
6257
+ function groupSourceQuery(query, group, schema) {
6258
+ if (!query.grouping) return null;
6259
+ const terms = groupKeyTerms(query.grouping.keys, group, schema);
6260
+ if (!terms) return null;
6261
+ const source = exitGrouping(query);
6262
+ return { ...source, where: appendWhereTerms(source.where, terms), offset: 0 };
6263
+ }
5523
6264
  export {
5524
6265
  AGG_OPS_BY_TYPE,
5525
6266
  AGG_OPS_NEEDING_FIELD,
@@ -5528,6 +6269,7 @@ export {
5528
6269
  FORMULA_FUNCTIONS,
5529
6270
  FormulaError,
5530
6271
  GROUP_ID_FIELD,
6272
+ GROUP_LINK_RENDER,
5531
6273
  ITEM_DETAILS_COLUMN,
5532
6274
  MAX_AGGREGATIONS,
5533
6275
  MAX_GROUP_BY_FIELDS,
@@ -5554,6 +6296,7 @@ export {
5554
6296
  aggOpNeedsField,
5555
6297
  aggOpsForField,
5556
6298
  applyAggregations,
6299
+ applyPatch,
5557
6300
  applyQuery,
5558
6301
  boxSummary,
5559
6302
  coerceValue,
@@ -5571,18 +6314,24 @@ export {
5571
6314
  encodeQuery,
5572
6315
  evaluateMetricReductions,
5573
6316
  evaluateMetrics,
6317
+ exitGrouping,
5574
6318
  filterValues,
5575
6319
  formulaRuntime,
6320
+ groupLinkQuery,
5576
6321
  groupPreview,
6322
+ groupSourceQuery,
5577
6323
  hasRegex,
5578
6324
  histogramCounts,
5579
6325
  histogramEdges,
5580
6326
  httpComputedColumnStore,
5581
6327
  httpQuerySnapshotStore,
5582
6328
  indexFields,
6329
+ inspectBucketQuery,
5583
6330
  isComputedCellError,
5584
6331
  isComputedField,
5585
6332
  isFilterable,
6333
+ isGroupLinkField,
6334
+ isGroupLinkValue,
5586
6335
  isGroupable,
5587
6336
  isMeasurable,
5588
6337
  isNegativePredicate,
@@ -5597,6 +6346,7 @@ export {
5597
6346
  localQuerySnapshotCache,
5598
6347
  localStorageAdapter,
5599
6348
  mapSetFilterValues,
6349
+ matchPatch,
5600
6350
  matchesClause,
5601
6351
  memoryComputedColumnStore,
5602
6352
  memoryPropertyViewStorage,
@@ -5611,6 +6361,7 @@ export {
5611
6361
  metricDependencies,
5612
6362
  metricExpression,
5613
6363
  metricPlans,
6364
+ metricPopulationError,
5614
6365
  negateClause,
5615
6366
  normalizePropertyFormat,
5616
6367
  normalizePropertyView,
@@ -5628,10 +6379,14 @@ export {
5628
6379
  readObjectReference,
5629
6380
  readSetFilter,
5630
6381
  reduceMetricValues,
6382
+ removePatch,
5631
6383
  renderItemPageTemplate,
5632
6384
  resolveFieldName,
6385
+ resolveGroupLink,
5633
6386
  selectMetricShownRows,
5634
6387
  selectedFields,
6388
+ setFilterModes,
6389
+ setFilterTarget,
5635
6390
  sourceDependencies,
5636
6391
  sourceExecutionSchema,
5637
6392
  stripSetFilter,
@@ -5646,6 +6401,8 @@ export {
5646
6401
  validateMetric,
5647
6402
  validateQueryResultV3,
5648
6403
  validateQuerySnapshot,
5649
- withoutGrouping
6404
+ withGroupingKeys,
6405
+ withoutGrouping,
6406
+ withoutLinkSummaries
5650
6407
  };
5651
6408
  //# sourceMappingURL=index.js.map