@pythia-software/query-table-react 0.9.0 → 0.9.2

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/README.md CHANGED
@@ -120,3 +120,23 @@ and reject them through `validateQuery` so users can repair the selected tags.
120
120
  `api.aggregations.preview(clauses, signal)` evaluates a draft without modifying the query. `replace(clauses, expected?)` commits the entire dashboard as one undoable change and can reject a stale editor baseline. Presentation-only changes reuse computation. Advanced remote execution uses `fetchMetrics` plus explicit v2 `metricCapabilities`; v1 transports continue to support representable simple metrics. All-matching scope requires the complete client dataset or a server metric service. Shown-row fallback uses committed inputs and reports missing dependencies.
121
121
 
122
122
  See [the metrics integration guide](../../docs/metrics.md) for scopes, cancellation, worker-isolated regex, capability negotiation, computed revisions, and snapshot requirements.
123
+
124
+ For host-managed row windows, pass `metricTransport` to fetch metrics separately
125
+ while `clientRows` continues through local filtering, sorting, and paging.
126
+ `metricExecution` overrides the metric handshake independently of
127
+ `computedExecution`. Both default to the existing row transport/execution
128
+ selection. Computed grouping choices in `api.computed.catalogue` use the metric
129
+ transport and handshake, including definition revisions; row selection,
130
+ filtering, sorting, and value access keep their row capabilities.
131
+ Use `metricRowsReady(query)` to confirm that the external row window
132
+ matches the current query and origin before local shown-row evaluation or
133
+ previews. Remote `allMatching` metrics run while that gate is false.
134
+
135
+ A V2 transport must explicitly advertise `metricCapabilities.shownRows: true`
136
+ to execute supported shown-row clauses remotely. False or omitted uses the
137
+ committed page, provided its inputs are loaded. See the
138
+ [migration example](../../docs/metrics.md#independent-remote-metrics).
139
+
140
+ Standalone `useAggregations` calls with a complete client dataset derive the
141
+ shown page when readiness options are omitted. An explicit `rowsReady: false`
142
+ still blocks automatic shown-row evaluation and previews.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { FieldSchema, WhereClause, QueryState, GroupedRow, GroupingModel, GroupingView, SummaryOp, RepresentativeRow, SummaryColumn, SelectColumn, PropertyViewState, SavedPropertyView, PropertyViewStorage, QueryLink, QuerySnapshot, ItemPropertyMode, ItemPageOptions, ItemDetailMode, ItemDetailsState, RelationshipAdapter, RowId, FormulaNode, FormulaValue, FormulaResult, ComputedColumn, FieldDef, ComputedColumnDraft, FormulaPlan, ColumnPreview, SavedQuery, StorageAdapter, AggOp, AggregationClause, AggregationResult, DistinctValuesResult, FieldStats, OrderByClause, Transport, ComputedColumnStore, ComputedExecution, QueryLinkOptions, ObjectReference, ResolvedObjectReference } from '@pythia-software/query-table-core';
1
+ import { FieldSchema, WhereClause, QueryState, GroupedRow, GroupingModel, GroupingView, SummaryOp, RepresentativeRow, SummaryColumn, SelectColumn, PropertyViewState, SavedPropertyView, PropertyViewStorage, QueryLink, QuerySnapshot, ItemPropertyMode, ItemPageOptions, ItemDetailMode, ItemDetailsState, RelationshipAdapter, RowId, FormulaNode, FormulaValue, FormulaResult, ComputedColumn, FieldDef, ComputedColumnDraft, FormulaPlan, ColumnPreview, SavedQuery, StorageAdapter, AggOp, AggregationClause, AggregationResult, DistinctValuesResult, FieldStats, OrderByClause, Transport, ComputedExecution, ComputedColumnStore, QueryLinkOptions, ObjectReference, ResolvedObjectReference } from '@pythia-software/query-table-core';
2
2
 
3
3
  /** Filter controls for either the result query or grouping's source query. */
4
4
  type QueryFilterApi<Row> = Pick<QueryTableApi<Row>, "query" | "setQuery" | "loading" | "addFilter" | "updateFilter" | "removeFilter" | "clearFilters" | "updatePredicate" | "removePredicate" | "negatePredicate" | "reorderFilters" | "mergeFilters" | "filterValues" | "fieldStats">;
@@ -266,8 +266,15 @@ interface UseQueryTableOptions<Row> {
266
266
  /** Disable row-producing grouping for this instance. Enabled for complete client datasets by default. */
267
267
  grouping?: boolean;
268
268
  schema: FieldSchema<Row>;
269
- /** Data access. Omit for a purely client-side table driven by `clientRows`. */
269
+ /** Row data access. Omit to filter, sort, and page `clientRows` locally. */
270
270
  transport?: Transport<Row>;
271
+ /** Metric-only data access; defaults to transport and never selects server row mode. */
272
+ metricTransport?: Transport<Row>;
273
+ /** Authoritative metric handshake; defaults to the existing row execution selection. */
274
+ metricExecution?: ComputedExecution;
275
+ /** Additional host readiness gate for committed-page metrics and previews.
276
+ * Does not delay remote allMatching requests. Re-evaluated on each render. */
277
+ metricRowsReady?: (query: QueryState) => boolean;
271
278
  /** Dataset already available in memory. Remote grouping/aggregation belongs
272
279
  * on the server; do not download a population to supply this option. */
273
280
  clientRows?: Row[];
package/dist/index.js CHANGED
@@ -828,6 +828,8 @@ function evaluateFormulaRows(ast, inputs, signal, factory = createFormulaWorker)
828
828
  // src/useComputedColumns.ts
829
829
  function useComputedColumns(options) {
830
830
  const { schema, query, rows, transport, clientRows, workerFactory } = options;
831
+ const metricTransport = options.metricTransport ?? transport;
832
+ const metricExecution = options.metricExecution ?? options.execution;
831
833
  const store = useMemo3(
832
834
  () => options.store ?? memoryComputedColumnStore(),
833
835
  [options.store]
@@ -1042,7 +1044,7 @@ function useComputedColumns(options) {
1042
1044
  },
1043
1045
  aggregate: {
1044
1046
  measure: !!entry?.plan,
1045
- groupable: (!!transport?.fetchMetrics && transport.metricCapabilities?.version === 2 || !!transport?.fetchQueryV3) && !!options.execution?.fields[computedFieldName(def.id)]?.group && executionRevision(options.execution, def.id) === def.revision && !!entry?.plan && !hasRegex(entry.plan)
1047
+ groupable: (!!metricTransport?.fetchMetrics && metricTransport.metricCapabilities?.version === 2 || !!metricTransport?.fetchQueryV3) && !!metricExecution?.fields[computedFieldName(def.id)]?.group && executionRevision(metricExecution, def.id) === def.revision && !!entry?.plan && !hasRegex(entry.plan)
1046
1048
  }
1047
1049
  };
1048
1050
  });
@@ -1076,6 +1078,8 @@ function useComputedColumns(options) {
1076
1078
  loading,
1077
1079
  transport,
1078
1080
  options.execution,
1081
+ metricTransport,
1082
+ metricExecution,
1079
1083
  options.serverComputed,
1080
1084
  options.serverExecution
1081
1085
  ]);
@@ -1189,7 +1193,7 @@ import {
1189
1193
  toServerQuery as toServerQuery2,
1190
1194
  queriesEqual as queriesEqual2,
1191
1195
  isOrGroup as isOrGroup2,
1192
- metricPlans as metricPlans3,
1196
+ metricPlans as metricPlans2,
1193
1197
  validateMetric as validateMetric2,
1194
1198
  metricDependencies,
1195
1199
  isComputedField as isComputedField4,
@@ -1261,7 +1265,7 @@ function observeTransport(transport, publish, nextId) {
1261
1265
  }
1262
1266
  });
1263
1267
  }
1264
- function useRequestActivity(source) {
1268
+ function useRequestActivity(source, metricSource) {
1265
1269
  const [entries, setEntries] = useState5([]);
1266
1270
  const sequence = useRef5(0), mounted = useRef5(true);
1267
1271
  useEffect5(() => {
@@ -1284,19 +1288,21 @@ function useRequestActivity(source) {
1284
1288
  () => setEntries((previous) => previous.filter((e) => e.status === "pending")),
1285
1289
  []
1286
1290
  );
1291
+ const metricTransport = useMemo4(
1292
+ () => !metricSource || metricSource === source ? transport : observeTransport(metricSource, publish, () => ++sequence.current),
1293
+ [metricSource, source, transport, publish]
1294
+ );
1287
1295
  const activity = useMemo4(
1288
1296
  () => ({ entries, clear }),
1289
1297
  [entries, clear]
1290
1298
  );
1291
- return { transport, activity };
1299
+ return { transport, metricTransport, activity };
1292
1300
  }
1293
1301
 
1294
1302
  // src/useAggregations.ts
1295
1303
  import { useCallback as useCallback6, useEffect as useEffect6, useMemo as useMemo5, useRef as useRef6, useState as useState6 } from "react";
1296
1304
  import {
1297
- metricComputationKey,
1298
- metricPlans as metricPlans2,
1299
- hasRegex as hasRegex3
1305
+ metricComputationKey
1300
1306
  } from "@pythia-software/query-table-core";
1301
1307
 
1302
1308
  // src/metricExecution.ts
@@ -1319,6 +1325,9 @@ import {
1319
1325
  var abort = (signal) => {
1320
1326
  if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
1321
1327
  };
1328
+ function usesLocalMetricPage(clause, context) {
1329
+ return !context.executeV3 && clause.scope === "shownRows" && (!context.transport?.fetchMetrics || context.transport.metricCapabilities?.version !== 2 || context.transport.metricCapabilities.shownRows !== true || !!clause.dateGrouping || metricPlans(clause, context.schema, context.resolveComputed).some(hasRegex2));
1330
+ }
1322
1331
  async function localMetrics(rows, query, context, signal) {
1323
1332
  const originalClauses = query.aggregations ?? [];
1324
1333
  const advanced = (query.aggregations ?? []).filter(
@@ -1433,16 +1442,13 @@ async function executeMetrics(query, context, signal) {
1433
1442
  const clauses = query.aggregations ?? [];
1434
1443
  if (!clauses.length) return { metrics: [] };
1435
1444
  if (context.executeV3) return context.executeV3(query, signal);
1445
+ if (context.rowsReady === false && clauses.some((c) => usesLocalMetricPage(c, context)))
1446
+ throw Error("Wait for the current table page before previewing shown-row metrics.");
1436
1447
  const supportsV2 = !!context.transport?.fetchMetrics && context.transport.metricCapabilities?.version === 2;
1437
1448
  if (context.clientRows && !supportsV2 && !context.transport?.fetchAggregations)
1438
1449
  return localMetrics(context.clientRows, query, context, signal);
1439
- const needsWorker = (c) => metricPlans(c, context.schema, context.resolveComputed).some(hasRegex2);
1440
- const shown = clauses.filter(
1441
- (c) => c.scope === "shownRows" && (!supportsV2 || needsWorker(c) || !!c.dateGrouping)
1442
- );
1443
- let matching = clauses.filter(
1444
- (c) => c.scope !== "shownRows" || supportsV2 && !needsWorker(c) && !c.dateGrouping
1445
- );
1450
+ const shown = clauses.filter((c) => usesLocalMetricPage(c, context));
1451
+ let matching = clauses.filter((c) => !shown.includes(c));
1446
1452
  const results = [];
1447
1453
  if (shown.length) {
1448
1454
  if (!context.rowsReady || !context.shownRows)
@@ -1608,140 +1614,153 @@ function loadedPath(row, path) {
1608
1614
  }
1609
1615
 
1610
1616
  // src/useAggregations.ts
1611
- function useAggregations(query, schema, transport, clientRows, debounceMs, nonce, validateQuery, options = {}, enabled = true) {
1612
- const [results, setResults] = useState6(null);
1613
- const [loading, setLoading] = useState6(false);
1614
- const [error, setError] = useState6(null);
1615
- const latest = useRef6({
1616
- query,
1617
- schema,
1618
- transport,
1619
- clientRows,
1620
- options,
1617
+ function localPage(clause, context) {
1618
+ try {
1619
+ return usesLocalMetricPage(clause, context);
1620
+ } catch {
1621
+ return !context.executeV3 && clause.scope === "shownRows";
1622
+ }
1623
+ }
1624
+ function useMetricBatch(query, clauses, context, debounceMs, nonce, validateQuery, enabled, page) {
1625
+ const shown = clauses.some((c) => c.scope === "shownRows");
1626
+ const key = JSON.stringify([
1627
+ query.where,
1628
+ metricComputationKey(clauses),
1629
+ shown ? [query.orderBy, query.limit, query.offset] : null,
1630
+ context.revisions ?? {},
1631
+ context.execution ?? null,
1632
+ context.now,
1633
+ context.strictLocalMetrics,
1634
+ context.queryContextKey,
1635
+ page ? context.shownFields : null
1636
+ ]);
1637
+ const localDataset = !context.executeV3 && !(context.transport?.fetchMetrics && context.transport.metricCapabilities?.version === 2) && !context.transport?.fetchAggregations;
1638
+ const pageReady = context.rowsReady ?? (localDataset && context.clientRows !== void 0);
1639
+ const validationToken = useMemo5(() => ({}), [
1640
+ key,
1641
+ context.schema,
1642
+ context.transport,
1643
+ nonce,
1621
1644
  validateQuery,
1622
1645
  enabled
1623
- });
1624
- latest.current = {
1625
- query,
1626
- schema,
1627
- transport,
1628
- clientRows,
1629
- options,
1646
+ ]);
1647
+ const token = useMemo5(() => ({}), [
1648
+ key,
1649
+ context.schema,
1650
+ context.transport,
1651
+ localDataset ? context.clientRows : void 0,
1652
+ debounceMs,
1653
+ nonce,
1630
1654
  validateQuery,
1631
- enabled
1632
- };
1633
- const preview = useCallback6(
1634
- async (clauses, signal) => {
1635
- const current = latest.current;
1636
- if (!current.enabled) throw Error("Resolve the query link before previewing metrics.");
1637
- const draft = { ...current.query, aggregations: clauses };
1638
- current.validateQuery?.(draft);
1639
- return executeMetrics(
1640
- draft,
1641
- {
1642
- schema: current.schema,
1643
- transport: current.transport,
1644
- clientRows: current.clientRows,
1645
- ...current.options
1646
- },
1647
- signal
1648
- );
1649
- },
1650
- []
1651
- );
1652
- const shown = (query.aggregations ?? []).some((c) => c.scope === "shownRows");
1653
- const localPage = !options.executeV3 && shown && (!transport?.fetchMetrics || transport.metricCapabilities?.version !== 2 || (query.aggregations ?? []).some((clause) => {
1654
- if (clause.scope !== "shownRows") return false;
1655
- try {
1656
- return metricPlans2(clause, schema, options.resolveComputed).some(
1657
- hasRegex3
1658
- );
1659
- } catch {
1660
- return true;
1661
- }
1662
- }));
1663
- const key = useMemo5(
1664
- () => JSON.stringify([
1665
- query.where,
1666
- metricComputationKey(query.aggregations ?? []),
1667
- shown ? [query.orderBy, query.limit, query.offset] : null
1668
- ]),
1669
- [
1670
- query.where,
1671
- query.aggregations,
1672
- query.orderBy,
1673
- query.limit,
1674
- query.offset,
1675
- shown
1676
- ]
1677
- );
1678
- const dependencyKey = JSON.stringify([
1679
- options.revisions ?? {},
1680
- options.execution ?? null,
1681
- options.now,
1682
- options.strictLocalMetrics,
1683
- options.queryContextKey
1655
+ enabled,
1656
+ page,
1657
+ context.workerFactory,
1658
+ page ? context.shownRows : void 0,
1659
+ page ? context.rowsReady : void 0,
1660
+ page ? context.rowsError : void 0
1684
1661
  ]);
1662
+ const latest = useRef6({ query, clauses, context, validateQuery });
1663
+ latest.current = { query, clauses, context, validateQuery };
1664
+ const [state, setState] = useState6(
1665
+ { token, results: null, loading: enabled && clauses.length > 0, error: null }
1666
+ );
1685
1667
  useEffect6(() => {
1686
- if (!enabled || !latest.current.query.aggregations?.length) {
1687
- setResults(null);
1688
- setLoading(false);
1689
- setError(null);
1690
- return;
1691
- }
1668
+ if (!enabled || !latest.current.clauses.length) return;
1692
1669
  const ac = new AbortController();
1693
- setLoading(true);
1694
- setResults(null);
1695
- if (localPage && transport && !options.rowsReady) {
1696
- try {
1697
- latest.current.validateQuery?.(latest.current.query);
1698
- setError(options.rowsError ?? null);
1699
- if (options.rowsError) setLoading(false);
1700
- } catch (e) {
1701
- setError(e instanceof Error ? e : Error(String(e)));
1702
- setLoading(false);
1703
- }
1704
- return;
1705
- }
1706
1670
  const timer = setTimeout(() => {
1707
- setError(null);
1671
+ const current = latest.current;
1708
1672
  try {
1709
- latest.current.validateQuery?.(latest.current.query);
1673
+ current.validateQuery?.(current.query);
1710
1674
  } catch (e) {
1711
- setError(e instanceof Error ? e : Error(String(e)));
1712
- setLoading(false);
1675
+ setState({ token, validationToken, results: null, loading: false, error: e instanceof Error ? e : Error(String(e)) });
1713
1676
  return;
1714
1677
  }
1715
- void preview(latest.current.query.aggregations ?? [], ac.signal).then((result) => {
1716
- if (!ac.signal.aborted) setResults(result);
1678
+ if (page && !pageReady) return;
1679
+ void executeMetrics({ ...current.query, aggregations: current.clauses }, current.context, ac.signal).then((results) => {
1680
+ if (!ac.signal.aborted) setState({ token, results, loading: false, error: null });
1717
1681
  }).catch((e) => {
1718
- if (!ac.signal.aborted)
1719
- setError(e instanceof Error ? e : Error(String(e)));
1720
- }).finally(() => {
1721
- if (!ac.signal.aborted) setLoading(false);
1682
+ if (!ac.signal.aborted) setState({ token, results: null, loading: false, error: e instanceof Error ? e : Error(String(e)) });
1722
1683
  });
1723
1684
  }, debounceMs);
1724
1685
  return () => {
1725
1686
  ac.abort();
1726
1687
  clearTimeout(timer);
1727
1688
  };
1728
- }, [
1729
- key,
1730
- schema,
1731
- transport,
1732
- clientRows,
1733
- debounceMs,
1734
- nonce,
1735
- validateQuery,
1736
- enabled,
1737
- dependencyKey,
1738
- localPage,
1739
- localPage ? options.shownRows : void 0,
1740
- localPage ? options.rowsReady : void 0,
1741
- localPage ? options.rowsError : void 0,
1742
- preview
1689
+ }, [token]);
1690
+ if (!enabled || !clauses.length) return { results: null, loading: false, error: null };
1691
+ if (state.validationToken === validationToken && state.error) return state;
1692
+ if (page && !pageReady && context.rowsError)
1693
+ return { results: null, loading: false, error: context.rowsError };
1694
+ if (state.token !== token) return { results: null, loading: true, error: null };
1695
+ return state;
1696
+ }
1697
+ function useAggregations(query, schema, transport, clientRows, debounceMs, nonce, validateQuery, options = {}, enabled = true) {
1698
+ const context = { schema, transport, clientRows, ...options };
1699
+ const clauses = query.aggregations ?? [];
1700
+ const pageClauses = clauses.filter((c) => localPage(c, context));
1701
+ const otherClauses = clauses.filter((c) => !pageClauses.includes(c));
1702
+ const other = useMetricBatch(query, otherClauses, context, debounceMs, nonce, validateQuery, enabled, false);
1703
+ const page = useMetricBatch(query, pageClauses, context, debounceMs, nonce, validateQuery, enabled, true);
1704
+ const previewKey = JSON.stringify([
1705
+ query.where,
1706
+ query.orderBy,
1707
+ query.limit,
1708
+ query.offset,
1709
+ metricComputationKey(clauses),
1710
+ options.revisions,
1711
+ options.execution,
1712
+ options.now,
1713
+ options.strictLocalMetrics,
1714
+ options.queryContextKey
1743
1715
  ]);
1744
- return { results, loading, error, preview };
1716
+ const previewToken = useMemo5(() => ({}), [previewKey, schema, transport, nonce, validateQuery, enabled, options.workerFactory]);
1717
+ const pageKey = JSON.stringify([query.orderBy, query.limit, query.offset, options.shownFields]);
1718
+ const pageToken = useMemo5(() => ({}), [previewToken, pageKey, clientRows, options.shownRows, options.rowsReady, options.rowsError]);
1719
+ const latest = useRef6({ query, context, validateQuery, enabled, previewToken, pageToken });
1720
+ latest.current = { query, context, validateQuery, enabled, previewToken, pageToken };
1721
+ const previews = useRef6(/* @__PURE__ */ new Set());
1722
+ useEffect6(() => () => {
1723
+ for (const pending of previews.current)
1724
+ if (pending.previewToken === previewToken) pending.controller.abort();
1725
+ }, [previewToken]);
1726
+ useEffect6(() => () => {
1727
+ for (const pending of previews.current)
1728
+ if (pending.page && pending.pageToken === pageToken) pending.controller.abort();
1729
+ }, [pageToken]);
1730
+ const preview = useCallback6(async (draftClauses, signal) => {
1731
+ const current = latest.current;
1732
+ if (!current.enabled) throw Error("Resolve the query link before previewing metrics.");
1733
+ const draft = { ...current.query, aggregations: draftClauses };
1734
+ current.validateQuery?.(draft);
1735
+ const pending = {
1736
+ controller: new AbortController(),
1737
+ page: draftClauses.some((c) => localPage(c, current.context)),
1738
+ previewToken: current.previewToken,
1739
+ pageToken: current.pageToken
1740
+ };
1741
+ const cancel = () => pending.controller.abort();
1742
+ signal?.addEventListener("abort", cancel, { once: true });
1743
+ if (signal?.aborted) cancel();
1744
+ previews.current.add(pending);
1745
+ try {
1746
+ const result = await executeMetrics(draft, current.context, pending.controller.signal);
1747
+ if (pending.controller.signal.aborted || current.previewToken !== latest.current.previewToken || pending.page && current.pageToken !== latest.current.pageToken)
1748
+ throw new DOMException("Aborted", "AbortError");
1749
+ return result;
1750
+ } finally {
1751
+ previews.current.delete(pending);
1752
+ signal?.removeEventListener("abort", cancel);
1753
+ }
1754
+ }, []);
1755
+ const orderKey = JSON.stringify(clauses.map((c) => c.id));
1756
+ const results = useMemo5(() => {
1757
+ if (!pageClauses.length) return other.results;
1758
+ if (!otherClauses.length) return page.results;
1759
+ if (!page.results || !other.results) return null;
1760
+ const byId = new Map([...page.results.metrics, ...other.results.metrics].map((m) => [m.id, m]));
1761
+ return { metrics: clauses.flatMap((c) => byId.get(c.id) ?? []) };
1762
+ }, [orderKey, pageClauses.length, otherClauses.length, page.results, other.results]);
1763
+ return { results, loading: page.loading || other.loading, error: page.error ?? other.error, preview };
1745
1764
  }
1746
1765
 
1747
1766
  // src/useSelection.ts
@@ -2248,7 +2267,7 @@ function useQueryTable(opts) {
2248
2267
  const active = opts.active !== false;
2249
2268
  const urlSyncEnabled = opts.syncUrl === true;
2250
2269
  const syncUrl = urlSyncEnabled && active;
2251
- const { transport: metadataTransport, activity: requestActivity } = useRequestActivity(sourceTransport);
2270
+ const { transport: metadataTransport, metricTransport, activity: requestActivity } = useRequestActivity(sourceTransport, opts.metricTransport);
2252
2271
  const transport = metadataTransport;
2253
2272
  const storage = useMemo9(
2254
2273
  () => opts.storage ?? memoryStorageAdapter(),
@@ -2478,6 +2497,8 @@ function useQueryTable(opts) {
2478
2497
  serverComputed,
2479
2498
  ...serverExecution ? { serverExecution } : {},
2480
2499
  ...opts.computedExecution ? { execution: opts.computedExecution } : {},
2500
+ ...metricTransport ? { metricTransport } : {},
2501
+ ...opts.metricExecution ? { metricExecution: opts.metricExecution } : {},
2481
2502
  ...opts.computedColumnStore ? { store: opts.computedColumnStore } : {},
2482
2503
  ...opts.formulaWorkerFactory ? { workerFactory: opts.formulaWorkerFactory } : {},
2483
2504
  ...transport ? { transport } : {},
@@ -2487,18 +2508,19 @@ function useQueryTable(opts) {
2487
2508
  (name) => isComputedField4(name) ? computed.compile(`[${name.replace(/]/g, "]] ".trim())}]`) : void 0,
2488
2509
  [computed.compile]
2489
2510
  );
2511
+ const metricCompileSchema = useMemo9(() => opts.metricExecution ? sourceExecutionSchema(displaySchema, opts.metricExecution, computed.definitions) : displaySchema, [displaySchema, opts.metricExecution, computed.definitions]);
2490
2512
  const metricCompile = useCallback12(
2491
2513
  (clause) => {
2492
2514
  const diagnostics = validateMetric2(
2493
2515
  clause,
2494
- displaySchema,
2516
+ metricCompileSchema,
2495
2517
  resolveComputed
2496
2518
  );
2497
2519
  if (diagnostics.length)
2498
2520
  throw new Error(diagnostics.map((d) => d.message).join(" "));
2499
- return metricPlans3(clause, displaySchema, resolveComputed);
2521
+ return metricPlans2(clause, metricCompileSchema, resolveComputed);
2500
2522
  },
2501
- [displaySchema, resolveComputed]
2523
+ [metricCompileSchema, resolveComputed]
2502
2524
  );
2503
2525
  const computedRevisions = useMemo9(
2504
2526
  () => Object.fromEntries(computed.definitions.map((d) => [d.id, d.revision])),
@@ -2759,11 +2781,18 @@ function useQueryTable(opts) {
2759
2781
  const dataToken = useRef9(0);
2760
2782
  const dataVersion = useMemo9(
2761
2783
  () => ++dataToken.current,
2762
- [schema, transport, clientRows, nonce, definitionKey, executionKey]
2784
+ [schema, transport, clientRows, rows, nonce, definitionKey, executionKey, metricTransport, opts.metricExecution]
2785
+ );
2786
+ const metricRowsReady = pageReady && (opts.metricRowsReady?.(query) ?? true);
2787
+ const metricExecution = opts.metricExecution ?? (opts.computedExecution ? pageReady && serverExecution ? serverExecution : opts.computedExecution : void 0);
2788
+ const v3MetricExecution = opts.metricExecution ?? opts.computedExecution;
2789
+ const metricSourceCatalogue = useMemo9(
2790
+ () => sourceExecutionSchema(sourceSchema, v3MetricExecution),
2791
+ [sourceSchema, v3MetricExecution]
2763
2792
  );
2764
2793
  const executeV3Metrics = useCallback12(async (metricQuery, signal) => {
2765
- if (!transport?.fetchQueryV3 || transport.queryCapabilities?.version !== 3) throw Error("The v3 query executor is unavailable.");
2766
- const capabilities = transport.queryCapabilities;
2794
+ if (!metricTransport?.fetchQueryV3 || metricTransport.queryCapabilities?.version !== 3) throw Error("The v3 query executor is unavailable.");
2795
+ const capabilities = metricTransport.queryCapabilities;
2767
2796
  const refused = /* @__PURE__ */ new Map();
2768
2797
  for (const clause of metricQuery.aggregations ?? []) {
2769
2798
  const issue = metricPopulationError2(clause, { filters: capabilities.metricFilters, minCount: capabilities.metricMinCount });
@@ -2777,12 +2806,12 @@ function useQueryTable(opts) {
2777
2806
  query.grouping ? { ...scoped, grouping: query.grouping } : scoped,
2778
2807
  sourceSchema,
2779
2808
  capabilities,
2780
- opts.computedExecution,
2809
+ v3MetricExecution,
2781
2810
  { now: groupingNow ?? (opts.evaluationNow ?? Date.now)(), rows: false }
2782
2811
  );
2783
2812
  if (request.diagnostics.length) throw Error(request.diagnostics.join(" "));
2784
- const result = await transport.fetchQueryV3(request, signal);
2785
- validateQueryResultV3(result, request, query.grouping ? schema : sourceCatalogue);
2813
+ const result = await metricTransport.fetchQueryV3(request, signal);
2814
+ validateQueryResultV3(result, request, query.grouping ? schema : metricSourceCatalogue);
2786
2815
  metrics = result.metrics;
2787
2816
  }
2788
2817
  if (!refused.size) return { metrics };
@@ -2793,7 +2822,7 @@ function useQueryTable(opts) {
2793
2822
  const metric = byId.get(clause.id);
2794
2823
  return metric ? [metric] : [];
2795
2824
  }) };
2796
- }, [transport, sourceSchema, sourceCatalogue, schema, opts.computedExecution, groupKey, groupingNow, opts.evaluationNow]);
2825
+ }, [metricTransport, sourceSchema, metricSourceCatalogue, schema, v3MetricExecution, groupKey, groupingNow, opts.evaluationNow]);
2797
2826
  const previewGrouping = useCallback12(async (draft, signal) => {
2798
2827
  if (!active || !storageReady || restoration.loading || restoration.error) throw Error("Wait for query restoration before previewing groups.");
2799
2828
  if (!transport?.fetchQueryV3 || !transport.queryCapabilities?.groupedRows || !draft.grouping) throw Error("The server group preview is unavailable.");
@@ -2812,23 +2841,21 @@ function useQueryTable(opts) {
2812
2841
  const aggState = useAggregations(
2813
2842
  executionQuery,
2814
2843
  schema,
2815
- transport,
2844
+ metricTransport,
2816
2845
  clientRows,
2817
2846
  debounceMs,
2818
2847
  nonce,
2819
2848
  validateExecution,
2820
2849
  {
2821
- ...transport?.fetchQueryV3 && transport.queryCapabilities?.version === 3 && (query.grouping || query.aggregations?.some((metric) => metric.dateGrouping) || query.where.some((term) => ("any" in term ? term.any : [term]).some((predicate) => isComputedField4(predicate.field)))) ? { executeV3: executeV3Metrics, queryContextKey: groupKey } : {},
2850
+ ...metricTransport?.fetchQueryV3 && metricTransport.queryCapabilities?.version === 3 && (query.grouping || query.aggregations?.some((metric) => metric.dateGrouping) || query.where.some((term) => ("any" in term ? term.any : [term]).some((predicate) => isComputedField4(predicate.field)))) ? { executeV3: executeV3Metrics, queryContextKey: groupKey } : {},
2822
2851
  ...groupingNow !== void 0 ? { now: groupingNow, strictLocalMetrics: true } : {},
2823
2852
  shownRows: rows,
2824
- rowsReady: pageReady,
2853
+ rowsReady: metricRowsReady,
2825
2854
  rowsError: error,
2826
2855
  ...transport ? { shownFields: committedFields } : {},
2827
2856
  resolveComputed,
2828
2857
  revisions: computedRevisions,
2829
- ...opts.computedExecution ? {
2830
- execution: pageReady && serverExecution ? serverExecution : opts.computedExecution
2831
- } : {},
2858
+ ...metricExecution ? { execution: metricExecution } : {},
2832
2859
  ...opts.formulaWorkerFactory ? { workerFactory: opts.formulaWorkerFactory } : {}
2833
2860
  },
2834
2861
  active && storageReady && !restoration.loading && !restoration.error
@@ -3100,7 +3127,7 @@ function useQueryTable(opts) {
3100
3127
  preview: aggState.preview,
3101
3128
  replace: replaceAggregations,
3102
3129
  compile: metricCompile,
3103
- contextKey: `${dataVersion}:${pageContext}:${pageReady}:${JSON.stringify(query.where)}`
3130
+ contextKey: `${dataVersion}:${pageContext}:${metricRowsReady}:${JSON.stringify(query.where)}`
3104
3131
  }),
3105
3132
  [
3106
3133
  query.aggregations,
@@ -3116,7 +3143,7 @@ function useQueryTable(opts) {
3116
3143
  replaceAggregations,
3117
3144
  metricCompile,
3118
3145
  pageContext,
3119
- pageReady,
3146
+ metricRowsReady,
3120
3147
  dataVersion,
3121
3148
  query.where
3122
3149
  ]