@uptimizr/db 0.6.0 → 0.7.1

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.
@@ -9,7 +9,7 @@
9
9
  * Invariant: no multi-tenant concepts here. Filtering is by `project_id` and the
10
10
  * optional scene/source/session dimensions only.
11
11
  */
12
- import { ParamBag, cameraModeClause, dayRangeClause, rangeClause, regionClause, sceneClause, sessionClause, sourceClause, } from "./dialect.js";
12
+ import { ParamBag, cameraModeClause, dayRangeClause, meshClause, rangeClause, regionClause, sceneClause, sessionClause, sourceClause, } from "./dialect.js";
13
13
  /** World/gaze voxel coordinates derive from the raycast `hit_point` vector. */
14
14
  const HIT_POINT_COLS = { x: "hit_point[1]", y: "hit_point[2]", z: "hit_point[3]" };
15
15
  /** Floor-plan cells derive from the camera `position` vector (Y is height). */
@@ -84,6 +84,48 @@ export function buildPointerHeatmap(projectId, opts, d) {
84
84
  query_params: bag.values,
85
85
  };
86
86
  }
87
+ /**
88
+ * Per-mesh texture-space (UV) heatmap (#149): bin the `uv` texture coordinates of
89
+ * interaction events (`pointer_click`, `mesh_interaction`, `hover_dwell`) on one
90
+ * mesh into a `bins x bins` grid over the object's own `[0, 1]` UV space. This is
91
+ * the surface-attention companion to the world-space voxel heatmap — "which part
92
+ * of the product model gets attention", independent of where the object sits in
93
+ * the scene. `uv` rides in the JSON `payload` (no promoted column), so it is read
94
+ * with the dialect's `jsonFloat`. The promoted `mesh` column already coalesces a
95
+ * pointer hit's `hitMesh` with an interaction's `mesh`, so one filter spans all
96
+ * three event types. Output matches {@link buildPointerHeatmap}'s `HeatmapBinRow`
97
+ * (`gx`, `gy`, `count`) so it reuses the 2D heatmap renderer.
98
+ */
99
+ export function buildMeshUvHeatmap(projectId, opts, d) {
100
+ const bag = new ParamBag(d);
101
+ const pid = bag.add("projectId", "string", projectId);
102
+ const bins = bag.add("bins", "u32", opts.bins ?? 50);
103
+ const range = rangeClause(bag, opts);
104
+ const scene = sceneClause(bag, opts);
105
+ const source = sourceClause(bag, opts);
106
+ const session = sessionClause(bag, opts);
107
+ const mesh = meshClause(bag, opts);
108
+ const u = d.jsonFloat("payload", "uv", "0");
109
+ const v = d.jsonFloat("payload", "uv", "1");
110
+ return {
111
+ query: `
112
+ SELECT gx, gy, count() AS count
113
+ FROM (
114
+ SELECT
115
+ floor(${u} * ${bins}) AS gx,
116
+ floor(${v} * ${bins}) AS gy
117
+ FROM events
118
+ WHERE project_id = ${pid}
119
+ AND event_type IN ('pointer_click', 'mesh_interaction', 'hover_dwell')
120
+ AND ${u} IS NOT NULL
121
+ AND ${v} IS NOT NULL${range}${scene}${source}${session}${mesh}
122
+ ) AS uv_hits
123
+ GROUP BY gx, gy
124
+ ORDER BY count DESC
125
+ `,
126
+ query_params: bag.values,
127
+ };
128
+ }
87
129
  /**
88
130
  * World-space (3D) pointer heatmap: voxel-bin the raycast hit points of pointer
89
131
  * events into a uniform grid of `cellSize`-sized cubes. Results are capped to the
@@ -251,6 +293,55 @@ export function buildCameraDirectionHeatmap(projectId, opts, d) {
251
293
  query_params: bag.values,
252
294
  };
253
295
  }
296
+ /**
297
+ * 360° view-coverage histogram (#146): how much of a 3D object each session
298
+ * actually looked at, bucketed across sessions. Each session's `camera_sample`
299
+ * `direction` samples are binned into the **same** azimuth/elevation grid as the
300
+ * view-direction dome ({@link buildCameraDirectionHeatmap}); the fraction of the
301
+ * `bins × bins` cells a session visited is its coverage score (0–100%). Sessions
302
+ * are then grouped into four coverage buckets — `0` (0–25%), `25` (25–50%), `50`
303
+ * (50–75%), `75` (75–100%) — answering "how many visitors saw <25% of the
304
+ * product". A single integer `bin_id = azimuth_bin * bins + elevation_bin` keeps
305
+ * the distinct-cell count cross-dialect (no `COUNT(DISTINCT a, b)`), and
306
+ * `least(floor(pct / 25), 3)` folds a full-coverage (100%) session into the top
307
+ * bucket instead of a spurious fifth `100` bucket. Purely derived from the
308
+ * existing `camera_sample` stream — no schema change.
309
+ */
310
+ export function buildViewCoverageHistogram(projectId, opts, d) {
311
+ const bag = new ParamBag(d);
312
+ const pid = bag.add("projectId", "string", projectId);
313
+ const bins = bag.add("bins", "u32", opts.bins ?? 36);
314
+ const range = rangeClause(bag, opts);
315
+ const scene = sceneClause(bag, opts);
316
+ const session = sessionClause(bag, opts);
317
+ const cameraMode = cameraModeClause(bag, d, projectId, opts);
318
+ return {
319
+ query: `
320
+ SELECT
321
+ least(floor(coverage_pct / 25), 3) * 25 AS bucket,
322
+ count() AS sessions
323
+ FROM (
324
+ SELECT
325
+ session_id,
326
+ count(DISTINCT bin_id) * 100.0 / (${bins} * ${bins}) AS coverage_pct
327
+ FROM (
328
+ SELECT
329
+ session_id,
330
+ floor((atan2(direction[3], direction[1]) + pi()) / (2 * pi()) * ${bins}) * ${bins}
331
+ + floor((asin(direction[2] / greatest(${d.vectorNorm("direction")}, 1e-6)) + pi() / 2) / pi() * ${bins}) AS bin_id
332
+ FROM events
333
+ WHERE project_id = ${pid}
334
+ AND event_type = 'camera_sample'
335
+ AND length(direction) = 3${range}${scene}${session}${cameraMode}
336
+ ) binned
337
+ GROUP BY session_id
338
+ ) per_session
339
+ GROUP BY bucket
340
+ ORDER BY bucket ASC
341
+ `,
342
+ query_params: bag.values,
343
+ };
344
+ }
254
345
  /**
255
346
  * Top-down "floor plan" camera-position heatmap (ADR 0026): bin `camera_sample`
256
347
  * world positions onto the X/Z ground plane in `cellSize`-sized cells, tracking the
@@ -699,6 +790,59 @@ export function buildMeshDwell(projectId, opts, d) {
699
790
  query_params: bag.values,
700
791
  };
701
792
  }
793
+ /**
794
+ * Blind-spot / never-noticed meshes (#143): the inverse of the Top-meshes and
795
+ * part-popularity leaderboards. Cross-references what was *rendered* against what
796
+ * was *engaged with* — per mesh, the total `mesh_visibility` on-screen time vs.
797
+ * the count of `mesh_interaction` events and the `hover_dwell` hesitation. A mesh
798
+ * with high visibility but zero (or near-zero) interaction + hover is a blind
799
+ * spot: a product detail nobody noticed, or a prop/room that renders but nobody
800
+ * investigates.
801
+ *
802
+ * Engagement is deliberately the two *active-attention* signals the issue names —
803
+ * `mesh_interaction` (the source-neutral pick/hover/drag signal, ADR 0011) and
804
+ * `hover_dwell` (hover-without-action, #48). Passive gaze (`camera_sample`) is
805
+ * not engagement, and raw `pointer_click` is excluded because a mesh-hitting click
806
+ * already surfaces as a `mesh_interaction`. Both durations live in the shared
807
+ * `visible_ms` column (events.ts maps `mesh_visibility.visibleMs` and
808
+ * `hover_dwell.dwellMs` onto it), so the whole report is one grouped scan.
809
+ *
810
+ * `HAVING sum(visible_ms) WHERE mesh_visibility > 0` keeps only meshes that were
811
+ * actually seen (a blind spot must first be visible). Ranked by engagement
812
+ * ascending, then visibility descending, so the most-seen-yet-least-touched
813
+ * meshes rank first.
814
+ */
815
+ export function buildMeshBlindSpots(projectId, opts, d) {
816
+ const bag = new ParamBag(d);
817
+ const pid = bag.add("projectId", "string", projectId);
818
+ const range = rangeClause(bag, opts);
819
+ const scene = sceneClause(bag, opts);
820
+ const session = sessionClause(bag, opts);
821
+ const limit = bag.add("limit", "u32", opts.limit ?? 25);
822
+ return {
823
+ query: `
824
+ SELECT
825
+ mesh,
826
+ sum(CASE WHEN event_type = 'mesh_visibility' THEN visible_ms ELSE 0 END) AS visible_ms,
827
+ sum(CASE WHEN event_type = 'mesh_visibility' THEN 1 ELSE 0 END) AS vis_samples,
828
+ sum(CASE WHEN event_type = 'mesh_interaction' THEN 1 ELSE 0 END) AS interactions,
829
+ sum(CASE WHEN event_type = 'hover_dwell' THEN visible_ms ELSE 0 END) AS hover_ms,
830
+ sum(CASE WHEN event_type = 'hover_dwell' THEN 1 ELSE 0 END) AS hover_episodes
831
+ FROM events
832
+ WHERE project_id = ${pid}
833
+ AND event_type IN ('mesh_visibility', 'mesh_interaction', 'hover_dwell')
834
+ AND mesh != ''${range}${scene}${session}
835
+ GROUP BY mesh
836
+ HAVING sum(CASE WHEN event_type = 'mesh_visibility' THEN visible_ms ELSE 0 END) > 0
837
+ ORDER BY
838
+ sum(CASE WHEN event_type = 'mesh_interaction' THEN 1 ELSE 0 END)
839
+ + sum(CASE WHEN event_type = 'hover_dwell' THEN 1 ELSE 0 END) ASC,
840
+ sum(CASE WHEN event_type = 'mesh_visibility' THEN visible_ms ELSE 0 END) DESC
841
+ LIMIT ${limit}
842
+ `,
843
+ query_params: bag.values,
844
+ };
845
+ }
702
846
  /**
703
847
  * Interaction-kind breakdown (#72): per-mesh counts of each interaction *kind*
704
848
  * (hover / pick / click / drag / select / squeeze / grab / release / teleport)
@@ -731,6 +875,73 @@ export function buildMeshInteractionKinds(projectId, opts, d) {
731
875
  query_params: bag.values,
732
876
  };
733
877
  }
878
+ /**
879
+ * Reachability report (#151): how far each interacted mesh sat from where the
880
+ * user actually stood. ASOF-join every `mesh_interaction` that carries a world
881
+ * `point` (→ `hit_point`) to the nearest **preceding** `camera_sample` in the
882
+ * same session, take the Euclidean standpoint→hit distance, and histogram it per
883
+ * mesh in `bucketSize`-wide world-unit bands. Meshes/UI whose interactions
884
+ * cluster in far bands are consistently reached from an uncomfortable range —
885
+ * actionable feedback for VR UI placement and first-person layout.
886
+ *
887
+ * The standpoint is the click-time camera **position** (the shared coordinate
888
+ * frame, ADR 0018); interactions with no preceding camera sample in range can't
889
+ * be measured, so the inner ASOF join drops them. Same nearest-in-time caveat as
890
+ * the click-gaze / navigation joins: camera samples are frequent enough that the
891
+ * approximation is sound for discrete interaction events. Honors the shared
892
+ * scene/source/session filters (source constrains the *interaction* side only —
893
+ * a `camera_sample`'s `source` is the realized `'mouse'` default, ADR 0011).
894
+ */
895
+ export function buildReachability(projectId, opts, d) {
896
+ const bag = new ParamBag(d);
897
+ const pid = bag.add("projectId", "string", projectId);
898
+ const bucketSize = bag.add("bucketSize", "f64", opts.bucketSize ?? 0.5);
899
+ const range = rangeClause(bag, opts);
900
+ const scene = sceneClause(bag, opts);
901
+ const source = sourceClause(bag, opts);
902
+ const session = sessionClause(bag, opts);
903
+ const limit = bag.add("limit", "u32", opts.limit ?? 500);
904
+ return {
905
+ query: `
906
+ SELECT
907
+ seg.mesh AS mesh,
908
+ floor(seg.dist / ${bucketSize}) AS bucket,
909
+ count() AS count,
910
+ avg(seg.dist) AS avg_distance
911
+ FROM (
912
+ SELECT
913
+ i.mesh AS mesh,
914
+ sqrt(
915
+ (i.hx - m.px) * (i.hx - m.px) +
916
+ (i.hy - m.py) * (i.hy - m.py) +
917
+ (i.hz - m.pz) * (i.hz - m.pz)
918
+ ) AS dist
919
+ FROM (
920
+ SELECT session_id, ts, mesh,
921
+ hit_point[1] AS hx, hit_point[2] AS hy, hit_point[3] AS hz
922
+ FROM events
923
+ WHERE project_id = ${pid}
924
+ AND event_type = 'mesh_interaction'
925
+ AND mesh != ''
926
+ AND length(hit_point) = 3${range}${scene}${source}${session}
927
+ ) AS i
928
+ ${d.asofInnerJoin} (
929
+ SELECT session_id, ts,
930
+ position[1] AS px, position[2] AS py, position[3] AS pz
931
+ FROM events
932
+ WHERE project_id = ${pid}
933
+ AND event_type = 'camera_sample'
934
+ AND length(position) = 3${range}${scene}${session}
935
+ ) AS m
936
+ ON i.session_id = m.session_id AND i.ts >= m.ts
937
+ ) AS seg
938
+ GROUP BY mesh, bucket
939
+ ORDER BY count DESC
940
+ LIMIT ${limit}
941
+ `,
942
+ query_params: bag.values,
943
+ };
944
+ }
734
945
  /**
735
946
  * Dead-click rate (#46): of all `pointer_click` events, how many hit nothing
736
947
  * (the hit-test missed, so `mesh` is empty / no `hitMesh`). A high dead-click
@@ -998,6 +1209,62 @@ export function buildGraphicsDiagnosticCounts(projectId, opts, d) {
998
1209
  query_params: bag.values,
999
1210
  };
1000
1211
  }
1212
+ /**
1213
+ * Spatial error heatmap (issue #154): voxel-bin the world `position` of
1214
+ * positioned `runtime_error` and `graphics_diagnostic` events into a uniform grid
1215
+ * of `cellSize`-sized cubes, returning the busiest `limit` voxels. This reveals
1216
+ * *where* in the scene things break — errors/crashes clustering around specific
1217
+ * geometry, a shader-heavy area, or a level region — instead of only *when*.
1218
+ *
1219
+ * `position` is best-effort connector-side (the camera pose at the moment the
1220
+ * error/diagnostic fired), so only rows that carry a full 3-vector participate
1221
+ * (`length(position) = 3`); errors from pages with no 3D connector are naturally
1222
+ * excluded. Reuses the already-promoted `position` column — no migration.
1223
+ *
1224
+ * Optional {@link ErrorHeatmapOptions} filters (severity/category/errorKind) read
1225
+ * from the `payload` JSON, mirroring {@link buildGraphicsDiagnosticCounts}: a
1226
+ * severity/category filter narrows to engine diagnostics, an errorKind filter
1227
+ * narrows to JS errors. `region` drill-down and `scene`/`session` scoping compose
1228
+ * like the other spatial heatmaps.
1229
+ */
1230
+ export function buildErrorHeatmap(projectId, opts, d) {
1231
+ const bag = new ParamBag(d);
1232
+ const pid = bag.add("projectId", "string", projectId);
1233
+ const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 1);
1234
+ const range = rangeClause(bag, opts);
1235
+ const scene = sceneClause(bag, opts);
1236
+ const session = sessionClause(bag, opts);
1237
+ const region = regionClause(bag, opts, POSITION_COLS);
1238
+ const limit = bag.add("limit", "u32", opts.limit ?? 1000);
1239
+ const filters = [];
1240
+ if (opts.severity != null && opts.severity.length > 0) {
1241
+ filters.push(`${d.jsonText("payload", "severity")} = ${bag.add("severity", "string", opts.severity)}`);
1242
+ }
1243
+ if (opts.category != null && opts.category.length > 0) {
1244
+ filters.push(`${d.jsonText("payload", "category")} = ${bag.add("category", "string", opts.category)}`);
1245
+ }
1246
+ if (opts.errorKind != null && opts.errorKind.length > 0) {
1247
+ filters.push(`${d.jsonText("payload", "kind")} = ${bag.add("errorKind", "string", opts.errorKind)}`);
1248
+ }
1249
+ const filter = filters.length ? ` AND ${filters.join(" AND ")}` : "";
1250
+ return {
1251
+ query: `
1252
+ SELECT
1253
+ floor(position[1] / ${cellSize}) AS vx,
1254
+ floor(position[2] / ${cellSize}) AS vy,
1255
+ floor(position[3] / ${cellSize}) AS vz,
1256
+ count() AS count
1257
+ FROM events
1258
+ WHERE project_id = ${pid}
1259
+ AND event_type IN ('runtime_error', 'graphics_diagnostic')
1260
+ AND length(position) = 3${range}${scene}${session}${region}${filter}
1261
+ GROUP BY vx, vy, vz
1262
+ ORDER BY count DESC
1263
+ LIMIT ${limit}
1264
+ `,
1265
+ query_params: bag.values,
1266
+ };
1267
+ }
1001
1268
  /**
1002
1269
  * Always-on rendering-technology mix from `session_start.graphics` (ADR 0021 part
1003
1270
  * 1): the fully-crossed `(api, backend, api_version, shading_language)` group with
@@ -1294,6 +1561,82 @@ export function buildJankRate(projectId, opts, d) {
1294
1561
  query_params: bag.values,
1295
1562
  };
1296
1563
  }
1564
+ /**
1565
+ * Perf-correlated churn (#144): does a stutter actually cost sessions? Correlates
1566
+ * perf dips against early session end. Of the sessions that ended in range
1567
+ * (`sessions`), `churn_sessions` ended within `windowMs` of an FPS dip (a
1568
+ * `frame_perf` sample below `fpsThreshold`) or a `compile_stall` of at least
1569
+ * `stallMs` — the felt hitches that plausibly drove the user away, as opposed to
1570
+ * background noise that the perf-distribution panel averages over.
1571
+ *
1572
+ * Semantics — a session churns iff it has a `session_end` **and** at least one
1573
+ * qualifying dip whose timestamp lies in `[end - windowMs, end]` (its earliest
1574
+ * `session_end` is the anchor). `fps_churn_sessions` / `stall_churn_sessions`
1575
+ * attribute the cause; a session whose window held both is counted in each cause
1576
+ * column but only once in `churn_sessions`, so the cause columns can sum to more
1577
+ * than the total.
1578
+ *
1579
+ * Implementation — an `ends` CTE (each session's first `session_end`) joined to a
1580
+ * `dips` CTE (the qualifying `frame_perf` / `compile_stall` rows) on `session_id`,
1581
+ * with the window enforced through the dialect's `epochMs` so the timestamp math
1582
+ * is engine-neutral. This uses only `JOIN` / `min` / `max` / `count` — **no window
1583
+ * or ASOF functions** — so it renders identically on DuckDB (OSS) and ClickHouse
1584
+ * (scale tier). Aggregating over the (possibly empty) `correlated` set always
1585
+ * yields one row; the `sessions` denominator is an uncorrelated scalar sub-select
1586
+ * so it stands even when nothing churned. Privacy (ADR 0003): aggregate counts
1587
+ * only, no per-session identifiers leave the query.
1588
+ */
1589
+ export function buildPerfChurn(projectId, opts, d) {
1590
+ const bag = new ParamBag(d);
1591
+ const pid = bag.add("projectId", "string", projectId);
1592
+ const range = rangeClause(bag, opts);
1593
+ const scene = sceneClause(bag, opts);
1594
+ const session = sessionClause(bag, opts);
1595
+ const windowMs = bag.add("windowMs", "u32", opts.windowMs ?? 30_000);
1596
+ const fpsThreshold = bag.add("fpsThreshold", "f64", opts.fpsThreshold ?? 30);
1597
+ const stallMs = bag.add("stallMs", "f64", opts.stallMs ?? 100);
1598
+ const dipTs = d.epochMs("dips.ts");
1599
+ const endTs = d.epochMs("ends.end_ts");
1600
+ return {
1601
+ query: `
1602
+ WITH ends AS (
1603
+ SELECT session_id, min(ts) AS end_ts
1604
+ FROM events
1605
+ WHERE project_id = ${pid} AND event_type = 'session_end'${range}${scene}${session}
1606
+ GROUP BY session_id
1607
+ ),
1608
+ dips AS (
1609
+ SELECT
1610
+ session_id,
1611
+ ts,
1612
+ CASE WHEN event_type = 'frame_perf' THEN 1 ELSE 0 END AS is_fps,
1613
+ CASE WHEN event_type = 'compile_stall' THEN 1 ELSE 0 END AS is_stall
1614
+ FROM events
1615
+ WHERE project_id = ${pid}${range}${scene}${session}
1616
+ AND (
1617
+ (event_type = 'frame_perf' AND fps < ${fpsThreshold})
1618
+ OR (event_type = 'compile_stall' AND visible_ms >= ${stallMs})
1619
+ )
1620
+ ),
1621
+ correlated AS (
1622
+ SELECT
1623
+ ends.session_id AS session_id,
1624
+ max(dips.is_fps) AS had_fps,
1625
+ max(dips.is_stall) AS had_stall
1626
+ FROM ends JOIN dips ON ends.session_id = dips.session_id
1627
+ WHERE ${dipTs} <= ${endTs} AND ${dipTs} >= ${endTs} - ${windowMs}
1628
+ GROUP BY ends.session_id
1629
+ )
1630
+ SELECT
1631
+ (SELECT count() FROM ends) AS sessions,
1632
+ count() AS churn_sessions,
1633
+ sum(had_fps) AS fps_churn_sessions,
1634
+ sum(had_stall) AS stall_churn_sessions
1635
+ FROM correlated
1636
+ `,
1637
+ query_params: bag.values,
1638
+ };
1639
+ }
1297
1640
  /**
1298
1641
  * FPS segmented by device class, computed **per-session then aggregated** (ADR
1299
1642
  * 0028 §2). Each session's median FPS is attributed to the graphics backend,
@@ -1547,6 +1890,47 @@ export function buildSceneCoverage(projectId, opts, d) {
1547
1890
  query_params: bag.values,
1548
1891
  };
1549
1892
  }
1893
+ /**
1894
+ * Spatial FPS heatmap (#145): voxel-bin `frame_perf` samples by their captured
1895
+ * camera `position` into a uniform grid of `cellSize`-sized cubes, reporting each
1896
+ * occupied cell's sample count, mean FPS, and worst single FPS. This answers
1897
+ * *where* performance degrades ("FPS is bad in the boss room"), the spatial
1898
+ * complement to the time-bucketed {@link buildPerfDistribution}/{@link buildFpsHistogram}.
1899
+ *
1900
+ * It reads the same promoted `position` column the camera-position heatmaps use —
1901
+ * `frame_perf` now carries an optional camera position, filled by the connector at
1902
+ * sample time — so no join against the separately-sampled `camera_sample` stream is
1903
+ * needed. Rows are ordered worst-FPS-first so the capped top-`limit` slice surfaces
1904
+ * the jankiest cells rather than an arbitrary corner of the scene.
1905
+ */
1906
+ export function buildPerfHeatmap(projectId, opts, d) {
1907
+ const bag = new ParamBag(d);
1908
+ const pid = bag.add("projectId", "string", projectId);
1909
+ const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 1);
1910
+ const range = rangeClause(bag, opts);
1911
+ const scene = sceneClause(bag, opts);
1912
+ const session = sessionClause(bag, opts);
1913
+ const limit = bag.add("limit", "u32", opts.limit ?? 2000);
1914
+ return {
1915
+ query: `
1916
+ SELECT
1917
+ floor(position[1] / ${cellSize}) AS vx,
1918
+ floor(position[2] / ${cellSize}) AS vy,
1919
+ floor(position[3] / ${cellSize}) AS vz,
1920
+ count() AS samples,
1921
+ avg(fps) AS avg_fps,
1922
+ min(fps) AS min_fps
1923
+ FROM events
1924
+ WHERE project_id = ${pid}
1925
+ AND event_type = 'frame_perf'
1926
+ AND length(position) = 3${range}${scene}${session}
1927
+ GROUP BY vx, vy, vz
1928
+ ORDER BY avg_fps ASC
1929
+ LIMIT ${limit}
1930
+ `,
1931
+ query_params: bag.values,
1932
+ };
1933
+ }
1550
1934
  /**
1551
1935
  * Camera distance / zoom distribution (derived, scene-metrics §B): histogram the
1552
1936
  * distance from the camera *position* of each `camera_sample` to a reference
@@ -1637,6 +2021,91 @@ export function buildNavigationStats(projectId, opts, d) {
1637
2021
  query_params: bag.values,
1638
2022
  };
1639
2023
  }
2024
+ /**
2025
+ * Path-retrace / backtracking ratio (#153): a confusion signal derived from the
2026
+ * same `camera_sample` position stream that feeds desire lines, surfaced as a
2027
+ * per-scene leaderboard. It answers "which areas do visitors keep re-walking?" —
2028
+ * a high backtrack ratio flags a dead end, a missed cue, or a puzzle that isn't
2029
+ * reading clearly.
2030
+ *
2031
+ * Algorithm — the coarse-grid revisit proxy (the cheap first cut, not true
2032
+ * reverse-segment retracing): bin each session's positions onto a `cellSize`
2033
+ * X/Z grid, then collapse consecutive samples in the same cell into ordered cell
2034
+ * *entries* (so standing still / dwelling never counts) via an ASOF self-join to
2035
+ * the immediately preceding sample. A row is an entry when it has no predecessor
2036
+ * (the session's first sample) or its cell differs from the predecessor's. The
2037
+ * `present` sentinel makes the unmatched-predecessor test engine-agnostic:
2038
+ * DuckDB null-fills a LEFT-join miss while ClickHouse zero-fills it, so `present`
2039
+ * (`1` only on a real match) is the portable "has a predecessor" flag.
2040
+ *
2041
+ * Per (session, scene): `revisits = entries − distinct_cells` — every entry into
2042
+ * a cell beyond its first is a re-entry. Pooled per scene, the leaderboard
2043
+ * reports `backtrack_ratio = Σ revisits / Σ entries` alongside the raw counts.
2044
+ * Only plain `count()` and a dedup subquery are used (no multi-column
2045
+ * `COUNT(DISTINCT …)`), so DuckDB and ClickHouse agree.
2046
+ */
2047
+ export function buildBacktrackRatio(projectId, opts, d) {
2048
+ const bag = new ParamBag(d);
2049
+ const pid = bag.add("projectId", "string", projectId);
2050
+ const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 2);
2051
+ const range = rangeClause(bag, opts);
2052
+ const scene = sceneClause(bag, opts);
2053
+ const session = sessionClause(bag, opts);
2054
+ const limit = bag.add("limit", "u32", opts.limit ?? 100);
2055
+ const sampleSelect = `
2056
+ SELECT session_id, scene_id AS scene, ts,
2057
+ floor(position[1] / ${cellSize}) AS gx,
2058
+ floor(position[3] / ${cellSize}) AS gz
2059
+ FROM events
2060
+ WHERE project_id = ${pid}
2061
+ AND event_type = 'camera_sample'
2062
+ AND length(position) = 3${range}${scene}${session}`;
2063
+ // Ordered cell entries (consecutive same-cell samples collapsed): a sample is
2064
+ // an entry when it has no predecessor or its cell changed vs. the predecessor.
2065
+ const entries = `
2066
+ SELECT c.session_id AS session_id, c.scene AS scene, c.gx AS gx, c.gz AS gz
2067
+ FROM (${sampleSelect}
2068
+ ) AS c
2069
+ ${d.asofLeftJoin} (
2070
+ SELECT session_id, ts, 1 AS present, gx, gz FROM (${sampleSelect}
2071
+ ) AS s
2072
+ ) AS m
2073
+ ON c.session_id = m.session_id AND c.ts > m.ts
2074
+ WHERE m.present IS NULL OR m.present = 0 OR c.gx <> m.gx OR c.gz <> m.gz`;
2075
+ return {
2076
+ query: `
2077
+ SELECT
2078
+ scene,
2079
+ count() AS sessions,
2080
+ sum(total_entries) AS entries,
2081
+ sum(revisits) AS revisits,
2082
+ CASE WHEN sum(total_entries) > 0
2083
+ THEN sum(revisits) * 1.0 / sum(total_entries) ELSE 0 END AS backtrack_ratio
2084
+ FROM (
2085
+ SELECT
2086
+ e.session_id AS session_id,
2087
+ e.scene AS scene,
2088
+ count() AS total_entries,
2089
+ count() - dc.distinct_cells AS revisits
2090
+ FROM (${entries}
2091
+ ) AS e
2092
+ JOIN (
2093
+ SELECT session_id, scene, count() AS distinct_cells
2094
+ FROM (
2095
+ SELECT DISTINCT session_id, scene, gx, gz FROM (${entries}
2096
+ ) AS de
2097
+ ) AS ded
2098
+ GROUP BY session_id, scene
2099
+ ) AS dc ON e.session_id = dc.session_id AND e.scene = dc.scene
2100
+ GROUP BY e.session_id, e.scene, dc.distinct_cells
2101
+ ) AS per_session
2102
+ GROUP BY scene
2103
+ ORDER BY backtrack_ratio DESC, entries DESC
2104
+ LIMIT ${limit}
2105
+ `,
2106
+ query_params: bag.values,
2107
+ };
2108
+ }
1640
2109
  /** XR input sources that distinguish hand-tracking, controllers and gaze. */
1641
2110
  const XR_SOURCES = "('xr-controller', 'hand', 'gaze', 'transient')";
1642
2111
  /**
@@ -1834,6 +2303,54 @@ export function buildXrAbandonment(projectId, opts, d) {
1834
2303
  query_params: bag.values,
1835
2304
  };
1836
2305
  }
2306
+ /**
2307
+ * XR locomotion & comfort (#148): per session that used an XR input source, its
2308
+ * locomotion-style breakdown and wall-clock span. This turns the existing
2309
+ * `camera_gesture` / `mesh_interaction` streams into a comfort signal for VR
2310
+ * developers — constant smooth `fly` locomotion (a motion-sickness risk) vs.
2311
+ * teleport-dominant sessions — and lets the consumer correlate heavy locomotion
2312
+ * with early exits (a short span, a discomfort / "rage-quit" proxy).
2313
+ *
2314
+ * A teleport emits **both** a `camera_gesture { kind: "fly" }` and a
2315
+ * `mesh_interaction { kind: "teleport" }` (ADR 0025), so `fly_gestures` counts
2316
+ * every fly (smooth + teleport) while `teleports` isolates the discrete jumps;
2317
+ * the consumer derives smooth locomotion as `fly_gestures - teleports`. Sessions
2318
+ * with no XR input are omitted entirely (same XR-session gate as
2319
+ * {@link buildXrAbandonment}). Wall-clock bounds are engine-specific and excluded
2320
+ * from parity; the counts are compared.
2321
+ */
2322
+ export function buildXrLocomotionComfort(projectId, opts, d) {
2323
+ const bag = new ParamBag(d);
2324
+ const pid = bag.add("projectId", "string", projectId);
2325
+ const range = rangeClause(bag, opts);
2326
+ const scene = sceneClause(bag, opts);
2327
+ const session = sessionClause(bag, opts);
2328
+ const limit = bag.add("limit", "u32", opts.limit ?? 500);
2329
+ return {
2330
+ query: `
2331
+ SELECT
2332
+ session_id,
2333
+ sum(CASE WHEN event_type = 'camera_gesture' AND name = 'fly' THEN 1 ELSE 0 END) AS fly_gestures,
2334
+ sum(CASE WHEN event_type = 'camera_gesture' AND name = 'navigate' THEN 1 ELSE 0 END) AS navigate_gestures,
2335
+ sum(CASE WHEN event_type = 'mesh_interaction' AND name = 'teleport' THEN 1 ELSE 0 END) AS teleports,
2336
+ sum(CASE WHEN event_type = 'camera_gesture' AND name IN ('fly', 'navigate') THEN visible_ms ELSE 0 END) AS locomotion_ms,
2337
+ min(ts) AS started_at,
2338
+ max(ts) AS ended_at
2339
+ FROM events
2340
+ WHERE project_id = ${pid}${range}${scene}${session}
2341
+ AND session_id IN (
2342
+ SELECT session_id
2343
+ FROM events
2344
+ WHERE project_id = ${pid}
2345
+ AND source IN ${XR_SOURCES}
2346
+ )
2347
+ GROUP BY session_id
2348
+ ORDER BY locomotion_ms DESC
2349
+ LIMIT ${limit}
2350
+ `,
2351
+ query_params: bag.values,
2352
+ };
2353
+ }
1837
2354
  /**
1838
2355
  * Render one funnel step's predicate against the wide `events` columns (ADR
1839
2356
  * 0038). Every field compiles to plain equality on a promoted column, so the
@@ -1918,4 +2435,280 @@ export function buildFunnel(projectId, opts, d) {
1918
2435
  query_params: bag.values,
1919
2436
  };
1920
2437
  }
2438
+ /**
2439
+ * Canned scene/level retention funnel (#147): session counts flowing scene →
2440
+ * scene in the order they were observed, built directly from `scene_change`
2441
+ * markers with **no caller-authored steps** (the zero-config complement to the
2442
+ * ADR 0038 funnel). Each `scene_change` envelope carries the scene now active in
2443
+ * `scene_id`, so a session's ordered `scene_change` targets are the levels it
2444
+ * moved through; every **consecutive pair** is a directed link.
2445
+ *
2446
+ * Semantics — for one session, order its `scene_change` events by `ts`; the link
2447
+ * `A → B` exists whenever `B`'s marker is the *next* `scene_change` after an `A`
2448
+ * marker. A link's weight is the number of **distinct sessions** that made that
2449
+ * consecutive transition, so it reads as level-to-level retention. Sessions with
2450
+ * a single `scene_change` contribute no link (there is no "from").
2451
+ *
2452
+ * Implementation — `sc` is the per-session ordered `scene_change` stream, and an
2453
+ * `ASOF INNER JOIN` matches each marker `a` to the single nearest later marker
2454
+ * `b` in the same session (`a.ts < b.ts`): exactly the "next event" shape, so the
2455
+ * link is `(a.scene_id → b.scene_id)`. ASOF renders through the {@link Dialect}
2456
+ * contract and needs only equality plus one inequality, so it runs on stock
2457
+ * ClickHouse with **no** `allow_experimental_join_condition` flag (the plain
2458
+ * self-join it replaces mixed left/right columns in an inequality `ON`, which
2459
+ * ClickHouse rejects without that experimental setting). Both engines resolve the
2460
+ * same nearest match, so the golden holds by transitivity. The final `GROUP BY`
2461
+ * counts distinct sessions per `(from_scene, to_scene)` pair; the last marker of
2462
+ * each session has no later match and is dropped by the inner join (no "to").
2463
+ */
2464
+ export function buildSceneRetention(projectId, opts, d) {
2465
+ const bag = new ParamBag(d);
2466
+ const pid = bag.add("projectId", "string", projectId);
2467
+ const range = rangeClause(bag, opts);
2468
+ const limit = bag.add("limit", "u32", opts.limit ?? 100);
2469
+ return {
2470
+ query: `
2471
+ WITH sc AS (
2472
+ SELECT session_id, ts, scene_id
2473
+ FROM events
2474
+ WHERE project_id = ${pid} AND event_type = 'scene_change'${range}
2475
+ )
2476
+ SELECT a.scene_id AS from_scene, b.scene_id AS to_scene,
2477
+ count(DISTINCT a.session_id) AS sessions
2478
+ FROM sc AS a
2479
+ ${d.asofInnerJoin} sc AS b
2480
+ ON a.session_id = b.session_id AND a.ts < b.ts
2481
+ GROUP BY from_scene, to_scene
2482
+ ORDER BY sessions DESC, from_scene ASC, to_scene ASC
2483
+ LIMIT ${limit}
2484
+ `,
2485
+ query_params: bag.values,
2486
+ };
2487
+ }
2488
+ /** Default load-time band boundaries (ms) for the load→bounce funnel (#152). */
2489
+ const DEFAULT_LOAD_BANDS = [1000, 3000, 5000];
2490
+ /**
2491
+ * Interaction event types that count as post-load engagement (#152). A session
2492
+ * that produces none of these at/after its initial load is a "bounce". Mirrors
2493
+ * the issue's `pointer_*` / `mesh_interaction` / `camera_gesture` set.
2494
+ */
2495
+ const INTERACTION_EVENT_TYPES = "('pointer_move', 'pointer_down', 'pointer_up', 'pointer_click', 'mesh_interaction', 'camera_gesture')";
2496
+ /**
2497
+ * Load → bounce/abandon funnel (#152): bucket sessions by their initial load
2498
+ * time and report how many **bounced** per band — a bounce being a session that
2499
+ * produced no interaction event (`pointer_*` / `mesh_interaction` /
2500
+ * `camera_gesture`) at or after its first `asset_load`. Turns "slow load costs
2501
+ * you customers" into a concrete per-band number.
2502
+ *
2503
+ * Semantics — a session's load time is the `loadMs` of its **earliest**
2504
+ * `asset_load` (the initial scene load). Engagement is any interaction event in
2505
+ * the same session at a timestamp `>=` that load event's timestamp, across
2506
+ * scenes — bounce is a session-level signal, so the engagement check is not
2507
+ * re-bounded by the range's `until` (a load near the window's end is not counted
2508
+ * as a false bounce). Sessions with no `asset_load` in scope are excluded.
2509
+ * `loadMs` lives in the `payload` JSON (it is not a promoted column), so it is
2510
+ * read with `jsonInt`.
2511
+ *
2512
+ * Bands come from `opts.bands` (ascending exclusive upper bounds in ms), or the
2513
+ * `[1000, 3000, 5000]` default → four bands. The builder emits a plain `CASE`
2514
+ * over the bound band values plus `JOIN` / `min` / `count` / `sum` — **no window
2515
+ * or ASOF functions** — so it renders identically on DuckDB (OSS) and ClickHouse
2516
+ * (scale tier). Band labels are the caller's concern.
2517
+ */
2518
+ export function buildLoadBounceFunnel(projectId, opts, d) {
2519
+ const bag = new ParamBag(d);
2520
+ const pid = bag.add("projectId", "string", projectId);
2521
+ const range = rangeClause(bag, opts);
2522
+ const scene = sceneClause(bag, opts);
2523
+ const loadMs = d.jsonInt("payload", "loadMs");
2524
+ const bands = opts.bands != null && opts.bands.length > 0 ? opts.bands : DEFAULT_LOAD_BANDS;
2525
+ const bandCase = `CASE\n${bands
2526
+ .map((upper, i) => ` WHEN load_ms < ${bag.add(`band${i}`, "f64", upper)} THEN ${i}`)
2527
+ .join("\n")}\n ELSE ${bands.length}\n END`;
2528
+ return {
2529
+ query: `
2530
+ WITH first_load AS (
2531
+ SELECT session_id, min(ts) AS load_ts
2532
+ FROM events
2533
+ WHERE project_id = ${pid} AND event_type = 'asset_load'${range}${scene}
2534
+ GROUP BY session_id
2535
+ ),
2536
+ load_ms AS (
2537
+ SELECT fl.session_id AS session_id, fl.load_ts AS load_ts,
2538
+ min(${loadMs}) AS load_ms
2539
+ FROM events AS e JOIN first_load AS fl
2540
+ ON e.session_id = fl.session_id AND e.ts = fl.load_ts
2541
+ WHERE e.project_id = ${pid} AND e.event_type = 'asset_load'
2542
+ GROUP BY fl.session_id, fl.load_ts
2543
+ ),
2544
+ engaged AS (
2545
+ SELECT lm.session_id AS session_id, count() AS interactions
2546
+ FROM events AS e JOIN load_ms AS lm
2547
+ ON e.session_id = lm.session_id
2548
+ WHERE e.project_id = ${pid}
2549
+ AND e.event_type IN ${INTERACTION_EVENT_TYPES}
2550
+ AND e.ts >= lm.load_ts
2551
+ GROUP BY lm.session_id
2552
+ )
2553
+ SELECT
2554
+ ${bandCase} AS band,
2555
+ count() AS sessions,
2556
+ sum(CASE WHEN coalesce(eng.interactions, 0) = 0 THEN 1 ELSE 0 END) AS bounced
2557
+ FROM load_ms AS lm LEFT JOIN engaged AS eng ON lm.session_id = eng.session_id
2558
+ WHERE lm.load_ms IS NOT NULL
2559
+ GROUP BY band
2560
+ ORDER BY band ASC
2561
+ `,
2562
+ query_params: bag.values,
2563
+ };
2564
+ }
2565
+ function leaderboardPredicate(bag, step, prefix) {
2566
+ const parts = [`event_type = ${bag.add(`${prefix}Type`, "string", step.type)}`];
2567
+ if (step.name != null && step.name.length > 0) {
2568
+ parts.push(`name = ${bag.add(`${prefix}Name`, "string", step.name)}`);
2569
+ }
2570
+ if (step.mesh != null && step.mesh.length > 0) {
2571
+ parts.push(`mesh = ${bag.add(`${prefix}Mesh`, "string", step.mesh)}`);
2572
+ }
2573
+ return parts.join(" AND ");
2574
+ }
2575
+ /**
2576
+ * Variant → conversion leaderboard for product configurators (#150).
2577
+ *
2578
+ * A **variant** is an event matching the `variant` predicate (default: every
2579
+ * `custom` event), grouped by its promoted `name` column — the color / material /
2580
+ * SKU discriminator configurators emit as custom-event names (payload `props` are
2581
+ * not portably queryable, so `name` is the grouping key; ADR 0038). Per variant
2582
+ * the leaderboard reports:
2583
+ *
2584
+ * - **views** — how many matching events fired, and over how many distinct
2585
+ * **sessions**;
2586
+ * - **conversions** — distinct sessions that fired the optional `conversion`
2587
+ * event at or after their first view of that variant (ordered, first-touch);
2588
+ * `0` when no `conversion` predicate is supplied. The consumer derives the rate
2589
+ * as `conversions / sessions`;
2590
+ * - **avg_dwell_ms** — the mean gap from each view to the next *boundary* in the
2591
+ * same session: a later view of a **different** variant (a switch) or a later
2592
+ * conversion event. A re-view of the *same* variant is not a boundary. Views
2593
+ * with no later boundary are excluded from the average.
2594
+ *
2595
+ * Implementation — a CTE chain using only `JOIN` / `min` / `avg` / `count` /
2596
+ * `UNION ALL` (no window or ASOF functions). Every join keys on `session_id`
2597
+ * alone and keeps its ordered / relative guards (`c.ts >= fv.t0`, the boundary
2598
+ * rule) in `WHERE`, so no `ON` mixes left/right columns in an inequality — it
2599
+ * renders identically on DuckDB (OSS) and ClickHouse (scale tier) and runs on
2600
+ * stock ClickHouse without the `allow_experimental_join_condition` flag
2601
+ * (ADR 0020). Injection-safe. Session scope (range / scene / camera-mode) applies
2602
+ * to both the variant and conversion event sets. Ranked by views, capped to
2603
+ * `limit`.
2604
+ *
2605
+ * The predicates come from the caller (request input / CLI / hosted) — OSS has no
2606
+ * authoring surface (ADR 0038).
2607
+ */
2608
+ export function buildVariantLeaderboard(projectId, opts, d) {
2609
+ const bag = new ParamBag(d);
2610
+ const pid = bag.add("projectId", "string", projectId);
2611
+ const range = rangeClause(bag, opts);
2612
+ const scene = sceneClause(bag, opts);
2613
+ const cameraMode = cameraModeClause(bag, d, projectId, opts);
2614
+ const limit = bag.add("limit", "u32", opts.limit ?? 50);
2615
+ const variantPred = leaderboardPredicate(bag, opts.variant ?? { type: "custom" }, "v");
2616
+ const hasConversion = opts.conversion != null;
2617
+ const conversionPred = hasConversion
2618
+ ? leaderboardPredicate(bag, opts.conversion, "c")
2619
+ : "";
2620
+ // Every variant event: (session, variant name, ts). `name` is the discriminator.
2621
+ const ctes = [
2622
+ `variant_views AS (
2623
+ SELECT session_id, name AS variant, ts
2624
+ FROM events
2625
+ WHERE project_id = ${pid} AND ${variantPred}${range}${scene}${cameraMode}
2626
+ )`,
2627
+ ];
2628
+ // Conversion events (optional): (session, ts), same session scope.
2629
+ if (hasConversion) {
2630
+ ctes.push(`conversions AS (
2631
+ SELECT session_id, ts
2632
+ FROM events
2633
+ WHERE project_id = ${pid} AND ${conversionPred}${range}${scene}${cameraMode}
2634
+ )`);
2635
+ }
2636
+ // Per-view aggregates: total views and distinct sessions per variant.
2637
+ ctes.push(`view_counts AS (
2638
+ SELECT variant, count() AS views, count(DISTINCT session_id) AS sessions
2639
+ FROM variant_views
2640
+ GROUP BY variant
2641
+ )`);
2642
+ // First time each session saw each variant — the ordered anchor for conversion.
2643
+ ctes.push(`first_view AS (
2644
+ SELECT session_id, variant, min(ts) AS t0
2645
+ FROM variant_views
2646
+ GROUP BY session_id, variant
2647
+ )`);
2648
+ // Distinct sessions that converted at/after first seeing the variant (ordered).
2649
+ // The join keys on `session_id` only and moves the ordered `c.ts >= fv.t0`
2650
+ // guard to WHERE, so the emitted `ON` carries no mixed left/right inequality —
2651
+ // it runs on stock ClickHouse without `allow_experimental_join_condition`.
2652
+ if (hasConversion) {
2653
+ ctes.push(`converted AS (
2654
+ SELECT fv.variant AS variant, count(DISTINCT fv.session_id) AS conversions
2655
+ FROM first_view fv
2656
+ JOIN conversions c ON c.session_id = fv.session_id
2657
+ WHERE c.ts >= fv.t0
2658
+ GROUP BY fv.variant
2659
+ )`);
2660
+ }
2661
+ // Boundaries for dwell: every variant view (carrying its variant, is_conv = 0)
2662
+ // plus every conversion event (is_conv = 1). A view's next boundary is the
2663
+ // earliest later boundary that is a conversion OR a different variant.
2664
+ const boundaryParts = [
2665
+ `SELECT session_id, ts, variant AS b_variant, 0 AS is_conv FROM variant_views`,
2666
+ ];
2667
+ if (hasConversion) {
2668
+ boundaryParts.push(`SELECT session_id, ts, '' AS b_variant, 1 AS is_conv FROM conversions`);
2669
+ }
2670
+ ctes.push(`boundaries AS (
2671
+ ${boundaryParts.join("\n UNION ALL ")}
2672
+ )`);
2673
+ // Per view: gap to its next boundary. The boundary rule mixes the view's own
2674
+ // `variant` with the candidate row, so it stays a plain equi-join on
2675
+ // `session_id` with the ordered / relative predicates in WHERE (an ASOF join
2676
+ // takes only one inequality and can't express the "different variant" guard).
2677
+ // Keeping the inequality out of `ON` lets stock ClickHouse plan it with no
2678
+ // `allow_experimental_join_condition`. The WHERE drops views with no boundary,
2679
+ // so they are excluded from the average (as specified).
2680
+ ctes.push(`view_dwell AS (
2681
+ SELECT v.variant AS variant,
2682
+ ${d.epochMs("min(b.ts)")} - ${d.epochMs("v.ts")} AS dwell_ms
2683
+ FROM variant_views v
2684
+ JOIN boundaries b
2685
+ ON b.session_id = v.session_id
2686
+ WHERE b.ts > v.ts
2687
+ AND NOT (b.is_conv = 0 AND b.b_variant = v.variant)
2688
+ GROUP BY v.session_id, v.variant, v.ts
2689
+ )`);
2690
+ ctes.push(`dwell AS (
2691
+ SELECT variant, avg(dwell_ms) AS avg_dwell_ms
2692
+ FROM view_dwell
2693
+ GROUP BY variant
2694
+ )`);
2695
+ const conversionsSelect = hasConversion ? `coalesce(cv.conversions, 0)` : `0`;
2696
+ const convJoin = hasConversion ? `\n LEFT JOIN converted cv ON cv.variant = vc.variant` : "";
2697
+ return {
2698
+ query: `
2699
+ WITH ${ctes.join(",\n ")}
2700
+ SELECT
2701
+ vc.variant AS variant,
2702
+ vc.views AS views,
2703
+ vc.sessions AS sessions,
2704
+ ${conversionsSelect} AS conversions,
2705
+ coalesce(dw.avg_dwell_ms, 0) AS avg_dwell_ms
2706
+ FROM view_counts vc
2707
+ LEFT JOIN dwell dw ON dw.variant = vc.variant${convJoin}
2708
+ ORDER BY vc.views DESC, vc.variant ASC
2709
+ LIMIT ${limit}
2710
+ `,
2711
+ query_params: bag.values,
2712
+ };
2713
+ }
1921
2714
  //# sourceMappingURL=aggregations.js.map