@uptimizr/db 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/AGENTS.md +109 -0
  2. package/LICENSE +201 -0
  3. package/README.md +89 -0
  4. package/dist/cli/createProject.d.ts +3 -0
  5. package/dist/cli/createProject.d.ts.map +1 -0
  6. package/dist/cli/createProject.js +41 -0
  7. package/dist/cli/createProject.js.map +1 -0
  8. package/dist/cli/migrate.d.ts +3 -0
  9. package/dist/cli/migrate.d.ts.map +1 -0
  10. package/dist/cli/migrate.js +21 -0
  11. package/dist/cli/migrate.js.map +1 -0
  12. package/dist/cli/seed.d.ts +2 -0
  13. package/dist/cli/seed.d.ts.map +1 -0
  14. package/dist/cli/seed.js +175 -0
  15. package/dist/cli/seed.js.map +1 -0
  16. package/dist/duckdb/client.d.ts +42 -0
  17. package/dist/duckdb/client.d.ts.map +1 -0
  18. package/dist/duckdb/client.js +93 -0
  19. package/dist/duckdb/client.js.map +1 -0
  20. package/dist/duckdb/events.d.ts +44 -0
  21. package/dist/duckdb/events.d.ts.map +1 -0
  22. package/dist/duckdb/events.js +225 -0
  23. package/dist/duckdb/events.js.map +1 -0
  24. package/dist/duckdb/migrations.d.ts +20 -0
  25. package/dist/duckdb/migrations.d.ts.map +1 -0
  26. package/dist/duckdb/migrations.js +323 -0
  27. package/dist/duckdb/migrations.js.map +1 -0
  28. package/dist/duckdb/projects.d.ts +23 -0
  29. package/dist/duckdb/projects.d.ts.map +1 -0
  30. package/dist/duckdb/projects.js +55 -0
  31. package/dist/duckdb/projects.js.map +1 -0
  32. package/dist/duckdb/queries.d.ts +10 -0
  33. package/dist/duckdb/queries.d.ts.map +1 -0
  34. package/dist/duckdb/queries.js +10 -0
  35. package/dist/duckdb/queries.js.map +1 -0
  36. package/dist/duckdb/sceneRegistry.d.ts +15 -0
  37. package/dist/duckdb/sceneRegistry.d.ts.map +1 -0
  38. package/dist/duckdb/sceneRegistry.js +98 -0
  39. package/dist/duckdb/sceneRegistry.js.map +1 -0
  40. package/dist/env.d.ts +25 -0
  41. package/dist/env.d.ts.map +1 -0
  42. package/dist/env.js +55 -0
  43. package/dist/env.js.map +1 -0
  44. package/dist/events.d.ts +107 -0
  45. package/dist/events.d.ts.map +1 -0
  46. package/dist/events.js +153 -0
  47. package/dist/events.js.map +1 -0
  48. package/dist/index.d.ts +36 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +34 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/metadata.d.ts +84 -0
  53. package/dist/metadata.d.ts.map +1 -0
  54. package/dist/metadata.js +23 -0
  55. package/dist/metadata.js.map +1 -0
  56. package/dist/parity/cases.d.ts +31 -0
  57. package/dist/parity/cases.d.ts.map +1 -0
  58. package/dist/parity/cases.js +524 -0
  59. package/dist/parity/cases.js.map +1 -0
  60. package/dist/parity/compare.d.ts +47 -0
  61. package/dist/parity/compare.d.ts.map +1 -0
  62. package/dist/parity/compare.js +90 -0
  63. package/dist/parity/compare.js.map +1 -0
  64. package/dist/parity/fixtures.d.ts +45 -0
  65. package/dist/parity/fixtures.d.ts.map +1 -0
  66. package/dist/parity/fixtures.js +157 -0
  67. package/dist/parity/fixtures.js.map +1 -0
  68. package/dist/parity/index.d.ts +12 -0
  69. package/dist/parity/index.d.ts.map +1 -0
  70. package/dist/parity/index.js +12 -0
  71. package/dist/parity/index.js.map +1 -0
  72. package/dist/query/aggregations.d.ts +393 -0
  73. package/dist/query/aggregations.d.ts.map +1 -0
  74. package/dist/query/aggregations.js +1412 -0
  75. package/dist/query/aggregations.js.map +1 -0
  76. package/dist/query/dialect.d.ts +100 -0
  77. package/dist/query/dialect.d.ts.map +1 -0
  78. package/dist/query/dialect.js +88 -0
  79. package/dist/query/dialect.js.map +1 -0
  80. package/dist/query/duckdbDialect.d.ts +27 -0
  81. package/dist/query/duckdbDialect.d.ts.map +1 -0
  82. package/dist/query/duckdbDialect.js +83 -0
  83. package/dist/query/duckdbDialect.js.map +1 -0
  84. package/dist/query/index.d.ts +22 -0
  85. package/dist/query/index.d.ts.map +1 -0
  86. package/dist/query/index.js +30 -0
  87. package/dist/query/index.js.map +1 -0
  88. package/dist/query/types.d.ts +388 -0
  89. package/dist/query/types.d.ts.map +1 -0
  90. package/dist/query/types.js +11 -0
  91. package/dist/query/types.js.map +1 -0
  92. package/llms.txt +21 -0
  93. package/package.json +71 -0
@@ -0,0 +1,1412 @@
1
+ /**
2
+ * Dialect-agnostic analytics aggregations (ADR 0020).
3
+ *
4
+ * Each builder describes an aggregation once and renders it through a
5
+ * {@link Dialect}, returning a {@link QuerySpec}. ClickHouse-specific syntax is
6
+ * confined to the dialect; the query *logic* lives here and is shared by every
7
+ * engine (DuckDB for OSS, single-tenant ClickHouse for the scale tier).
8
+ *
9
+ * Invariant: no multi-tenant concepts here. Filtering is by `project_id` and the
10
+ * optional scene/source/session dimensions only.
11
+ */
12
+ import { ParamBag, cameraModeClause, dayRangeClause, rangeClause, sceneClause, sessionClause, sourceClause, } from "./dialect.js";
13
+ /** List sessions for a project with event counts and time bounds. */
14
+ export function buildListSessions(projectId, opts, d) {
15
+ const bag = new ParamBag(d);
16
+ const pid = bag.add("projectId", "string", projectId);
17
+ const range = rangeClause(bag, opts);
18
+ const cameraMode = cameraModeClause(bag, d, projectId, opts);
19
+ const limit = bag.add("limit", "u32", opts.limit ?? 100);
20
+ return {
21
+ query: `
22
+ SELECT
23
+ session_id,
24
+ ${d.anyValue("visitor_id")} AS visitor_id,
25
+ count() AS events,
26
+ min(ts) AS started_at,
27
+ max(ts) AS ended_at
28
+ FROM events
29
+ WHERE project_id = ${pid}${range}${cameraMode}
30
+ GROUP BY session_id
31
+ ORDER BY started_at DESC
32
+ LIMIT ${limit}
33
+ `,
34
+ query_params: bag.values,
35
+ };
36
+ }
37
+ /**
38
+ * 2D pointer heatmap: bin normalized screen positions into a `bins x bins` grid.
39
+ * Covers `pointer_move` and `pointer_click`.
40
+ */
41
+ export function buildPointerHeatmap(projectId, opts, d) {
42
+ const bag = new ParamBag(d);
43
+ const pid = bag.add("projectId", "string", projectId);
44
+ const bins = bag.add("bins", "u32", opts.bins ?? 50);
45
+ const range = rangeClause(bag, opts);
46
+ const scene = sceneClause(bag, opts);
47
+ const source = sourceClause(bag, opts);
48
+ const session = sessionClause(bag, opts);
49
+ const cameraMode = cameraModeClause(bag, d, projectId, opts);
50
+ return {
51
+ query: `
52
+ SELECT
53
+ floor(screen[1] * ${bins}) AS gx,
54
+ floor(screen[2] * ${bins}) AS gy,
55
+ count() AS count
56
+ FROM events
57
+ WHERE project_id = ${pid}
58
+ AND event_type IN ('pointer_move', 'pointer_click')
59
+ AND length(screen) = 2${range}${scene}${source}${session}${cameraMode}
60
+ GROUP BY gx, gy
61
+ ORDER BY count DESC
62
+ `,
63
+ query_params: bag.values,
64
+ };
65
+ }
66
+ /**
67
+ * World-space (3D) pointer heatmap: voxel-bin the raycast hit points of pointer
68
+ * events into a uniform grid of `cellSize`-sized cubes. Results are capped to the
69
+ * busiest `limit` voxels.
70
+ */
71
+ export function buildWorldHeatmap(projectId, opts, d) {
72
+ const bag = new ParamBag(d);
73
+ const pid = bag.add("projectId", "string", projectId);
74
+ const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 0.5);
75
+ const range = rangeClause(bag, opts);
76
+ const scene = sceneClause(bag, opts);
77
+ const source = sourceClause(bag, opts);
78
+ const cameraMode = cameraModeClause(bag, d, projectId, opts);
79
+ const limit = bag.add("limit", "u32", opts.limit ?? 1000);
80
+ return {
81
+ query: `
82
+ SELECT
83
+ floor(hit_point[1] / ${cellSize}) AS vx,
84
+ floor(hit_point[2] / ${cellSize}) AS vy,
85
+ floor(hit_point[3] / ${cellSize}) AS vz,
86
+ count() AS count
87
+ FROM events
88
+ WHERE project_id = ${pid}
89
+ AND event_type IN ('pointer_move', 'pointer_click')
90
+ AND length(hit_point) = 3${range}${scene}${source}${cameraMode}
91
+ GROUP BY vx, vy, vz
92
+ ORDER BY count DESC
93
+ LIMIT ${limit}
94
+ `,
95
+ query_params: bag.values,
96
+ };
97
+ }
98
+ /**
99
+ * World-space (3D) gaze heatmap (ADR 0030): voxel-bin the camera-pose gaze
100
+ * surface hits (`camera_sample.hitPoint`) into the same uniform grid as the
101
+ * pointer world heatmap. This is the "what did people actually look at" map — it
102
+ * lands on real geometry and serves orbit/viewer, first-person, and XR scenes
103
+ * alike. Distinct from the click-driven world heatmap (looked-at vs clicked) and
104
+ * from the abstract direction sphere (surface vs angle). Results are capped to
105
+ * the busiest `limit` voxels; an optional `session` scopes it to one visit
106
+ * (ADR 0010 §1a). Gaze has no pointer input-source, so there is no `source` filter.
107
+ */
108
+ export function buildGazeHeatmap(projectId, opts, d) {
109
+ const bag = new ParamBag(d);
110
+ const pid = bag.add("projectId", "string", projectId);
111
+ const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 0.5);
112
+ const range = rangeClause(bag, opts);
113
+ const scene = sceneClause(bag, opts);
114
+ const session = sessionClause(bag, opts);
115
+ const cameraMode = cameraModeClause(bag, d, projectId, opts);
116
+ const limit = bag.add("limit", "u32", opts.limit ?? 1000);
117
+ return {
118
+ query: `
119
+ SELECT
120
+ floor(hit_point[1] / ${cellSize}) AS vx,
121
+ floor(hit_point[2] / ${cellSize}) AS vy,
122
+ floor(hit_point[3] / ${cellSize}) AS vz,
123
+ count() AS count
124
+ FROM events
125
+ WHERE project_id = ${pid}
126
+ AND event_type = 'camera_sample'
127
+ AND length(hit_point) = 3${range}${scene}${session}${cameraMode}
128
+ GROUP BY vx, vy, vz
129
+ ORDER BY count DESC
130
+ LIMIT ${limit}
131
+ `,
132
+ query_params: bag.values,
133
+ };
134
+ }
135
+ /**
136
+ * View-direction heatmap: bin camera forward vectors by spherical angles.
137
+ * `azimuth = atan2(z, x)`, `elevation = asin(y / |v|)`, each bucketed into `bins`.
138
+ */
139
+ export function buildCameraDirectionHeatmap(projectId, opts, d) {
140
+ const bag = new ParamBag(d);
141
+ const pid = bag.add("projectId", "string", projectId);
142
+ const bins = bag.add("bins", "u32", opts.bins ?? 36);
143
+ const range = rangeClause(bag, opts);
144
+ const scene = sceneClause(bag, opts);
145
+ const session = sessionClause(bag, opts);
146
+ const cameraMode = cameraModeClause(bag, d, projectId, opts);
147
+ return {
148
+ query: `
149
+ SELECT
150
+ floor((atan2(direction[3], direction[1]) + pi()) / (2 * pi()) * ${bins}) AS azimuth_bin,
151
+ floor((asin(direction[2] / greatest(${d.vectorNorm("direction")}, 1e-6)) + pi() / 2) / pi() * ${bins}) AS elevation_bin,
152
+ count() AS count
153
+ FROM events
154
+ WHERE project_id = ${pid}
155
+ AND event_type = 'camera_sample'
156
+ AND length(direction) = 3${range}${scene}${session}${cameraMode}
157
+ GROUP BY azimuth_bin, elevation_bin
158
+ ORDER BY count DESC
159
+ `,
160
+ query_params: bag.values,
161
+ };
162
+ }
163
+ /**
164
+ * Top-down "floor plan" camera-position heatmap (ADR 0026): bin `camera_sample`
165
+ * world positions onto the X/Z ground plane in `cellSize`-sized cells, tracking the
166
+ * mean height per cell. For first-person (`cameraType: "free"`) sessions this is
167
+ * the "where do visitors walk / dwell" map; capped to the busiest `limit` cells.
168
+ */
169
+ export function buildCameraPositionHeatmap(projectId, opts, d) {
170
+ const bag = new ParamBag(d);
171
+ const pid = bag.add("projectId", "string", projectId);
172
+ const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 1);
173
+ const range = rangeClause(bag, opts);
174
+ const scene = sceneClause(bag, opts);
175
+ const session = sessionClause(bag, opts);
176
+ const cameraMode = cameraModeClause(bag, d, projectId, opts);
177
+ const limit = bag.add("limit", "u32", opts.limit ?? 2000);
178
+ return {
179
+ query: `
180
+ SELECT
181
+ floor(position[1] / ${cellSize}) AS gx,
182
+ floor(position[3] / ${cellSize}) AS gz,
183
+ avg(position[2]) AS avg_y,
184
+ count() AS count
185
+ FROM events
186
+ WHERE project_id = ${pid}
187
+ AND event_type = 'camera_sample'
188
+ AND length(position) = 3${range}${scene}${session}${cameraMode}
189
+ GROUP BY gx, gz
190
+ ORDER BY count DESC
191
+ LIMIT ${limit}
192
+ `,
193
+ query_params: bag.values,
194
+ };
195
+ }
196
+ /**
197
+ * A single session's walked path (ADR 0026): the ordered `camera_sample` world
198
+ * positions for one session, oldest first. Drives the dashboard's trajectory view
199
+ * (a poly-line over the floor plan). Capped to `limit` points.
200
+ */
201
+ export function buildSessionTrajectory(projectId, opts, d) {
202
+ const bag = new ParamBag(d);
203
+ const pid = bag.add("projectId", "string", projectId);
204
+ const session = bag.add("session", "string", opts.session);
205
+ const range = rangeClause(bag, opts);
206
+ const scene = sceneClause(bag, opts);
207
+ const limit = bag.add("limit", "u32", opts.limit ?? 5000);
208
+ return {
209
+ query: `
210
+ SELECT
211
+ ${d.epochMs("ts")} AS ts,
212
+ position[1] AS x,
213
+ position[2] AS y,
214
+ position[3] AS z
215
+ FROM events
216
+ WHERE project_id = ${pid}
217
+ AND session_id = ${session}
218
+ AND event_type = 'camera_sample'
219
+ AND length(position) = 3${range}${scene}
220
+ ORDER BY ts ASC
221
+ LIMIT ${limit}
222
+ `,
223
+ query_params: bag.values,
224
+ };
225
+ }
226
+ /**
227
+ * Click ↔ gaze correlation: aggregate each `pointer_click` into a ray from an
228
+ * origin voxel to a hit voxel, where the voxel size matches the world heatmap so
229
+ * origins and hits share the same grid.
230
+ *
231
+ * Origin selection (ADR 0011 — source-agnostic): pose-enabled sources (XR
232
+ * controllers, hands, gaze) carry their own world-space pointing ray, so when a
233
+ * click has a `ray.origin` we use it verbatim — the controller/hand/gaze is the
234
+ * true pointing origin, not the headset/camera. Flat pointers (mouse, touch,
235
+ * stylus) have no native ray, so they fall back to the nearest preceding
236
+ * `camera_sample` in the same session (the historical view-gated behavior). The
237
+ * camera join is therefore a LEFT join so pose clicks survive even in sessions
238
+ * that never emit a `camera_sample`; rows with neither a ray nor a camera origin
239
+ * are dropped.
240
+ */
241
+ export function buildClickGazeRay(projectId, opts, d) {
242
+ const bag = new ParamBag(d);
243
+ const pid = bag.add("projectId", "string", projectId);
244
+ const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 0.5);
245
+ const range = rangeClause(bag, opts);
246
+ const scene = sceneClause(bag, opts);
247
+ const source = sourceClause(bag, opts);
248
+ const session = sessionClause(bag, opts);
249
+ return {
250
+ query: `
251
+ SELECT
252
+ floor(j.ox / ${cellSize}) AS cam_vx,
253
+ floor(j.oy / ${cellSize}) AS cam_vy,
254
+ floor(j.oz / ${cellSize}) AS cam_vz,
255
+ avg(j.ox) AS origin_x,
256
+ avg(j.oy) AS origin_y,
257
+ avg(j.oz) AS origin_z,
258
+ floor(j.hx / ${cellSize}) AS hit_vx,
259
+ floor(j.hy / ${cellSize}) AS hit_vy,
260
+ floor(j.hz / ${cellSize}) AS hit_vz,
261
+ avg(j.hx) AS hit_x,
262
+ avg(j.hy) AS hit_y,
263
+ avg(j.hz) AS hit_z,
264
+ j.mesh AS mesh,
265
+ count() AS count
266
+ FROM (
267
+ SELECT
268
+ c.hx AS hx, c.hy AS hy, c.hz AS hz, c.mesh AS mesh,
269
+ CASE WHEN c.has_ray THEN c.rox WHEN m.cam_present = 1 THEN m.px END AS ox,
270
+ CASE WHEN c.has_ray THEN c.roy WHEN m.cam_present = 1 THEN m.py END AS oy,
271
+ CASE WHEN c.has_ray THEN c.roz WHEN m.cam_present = 1 THEN m.pz END AS oz
272
+ FROM (
273
+ SELECT session_id, ts,
274
+ hit_point[1] AS hx, hit_point[2] AS hy, hit_point[3] AS hz, mesh,
275
+ length(ray_origin) = 3 AS has_ray,
276
+ ray_origin[1] AS rox, ray_origin[2] AS roy, ray_origin[3] AS roz
277
+ FROM events
278
+ WHERE project_id = ${pid}
279
+ AND event_type = 'pointer_click'
280
+ AND length(hit_point) = 3${range}${scene}${source}${session}
281
+ ) AS c
282
+ ${d.asofLeftJoin} (
283
+ SELECT session_id, ts, 1 AS cam_present,
284
+ position[1] AS px, position[2] AS py, position[3] AS pz
285
+ FROM events
286
+ WHERE project_id = ${pid}
287
+ AND event_type = 'camera_sample'
288
+ AND length(position) = 3${range}${scene}${session}
289
+ ) AS m
290
+ ON c.session_id = m.session_id AND c.ts >= m.ts
291
+ ) AS j
292
+ WHERE j.ox IS NOT NULL
293
+ GROUP BY cam_vx, cam_vy, cam_vz, hit_vx, hit_vy, hit_vz, mesh
294
+ ORDER BY count DESC
295
+ LIMIT ${bag.add("limit", "u32", opts.limit ?? 500)}
296
+ `,
297
+ query_params: bag.values,
298
+ };
299
+ }
300
+ /**
301
+ * Aggregate gaze→mesh flow links: ASOF-join each `pointer_click` to the nearest
302
+ * preceding `camera_sample` in the same session, then group by
303
+ * `(direction-bin, mesh)`. Each row is one weighted link from a direction bin to
304
+ * a clicked mesh.
305
+ *
306
+ * Position-aware mode (design §7.8): when `groupByOrigin` is set or an
307
+ * `originVoxel` filter is given, the click-time camera **position** is restored
308
+ * as a source dimension — rows additionally carry the standpoint voxel
309
+ * (`origin_v*`) and its averaged world point (`origin_*`). The standpoint origin
310
+ * prefers the click's own ray origin for pose sources (ADR 0011), falling back
311
+ * to the joined `camera_sample` position. Omitting both options reproduces the
312
+ * §7.5 direction-only links unchanged.
313
+ */
314
+ export function buildFlowHeatmap(projectId, opts, d) {
315
+ const bag = new ParamBag(d);
316
+ const pid = bag.add("projectId", "string", projectId);
317
+ const bins = bag.add("bins", "u32", opts.bins ?? 24);
318
+ const range = rangeClause(bag, opts);
319
+ const scene = sceneClause(bag, opts);
320
+ const session = sessionClause(bag, opts);
321
+ const cameraMode = cameraModeClause(bag, d, projectId, opts);
322
+ const limit = bag.add("limit", "u32", opts.limit ?? 150);
323
+ // §7.8 is opt-in: only restructure the query when a position dimension is asked
324
+ // for, so the default §7.5 flow renders byte-for-byte as before.
325
+ const positionAware = opts.groupByOrigin === true || opts.originVoxel != null;
326
+ if (!positionAware) {
327
+ return {
328
+ query: `
329
+ SELECT
330
+ floor((atan2(m.dz, m.dx) + pi()) / (2 * pi()) * ${bins}) AS azimuth_bin,
331
+ floor((asin(m.dy / greatest(sqrt(m.dx * m.dx + m.dy * m.dy + m.dz * m.dz), 1e-6)) + pi() / 2) / pi() * ${bins}) AS elevation_bin,
332
+ c.mesh AS mesh,
333
+ count() AS count
334
+ FROM (
335
+ SELECT session_id, ts, mesh
336
+ FROM events
337
+ WHERE project_id = ${pid}
338
+ AND event_type = 'pointer_click'
339
+ AND mesh != ''${range}${scene}${session}${cameraMode}
340
+ ) AS c
341
+ ${d.asofInnerJoin} (
342
+ SELECT session_id, ts, direction[1] AS dx, direction[2] AS dy, direction[3] AS dz
343
+ FROM events
344
+ WHERE project_id = ${pid}
345
+ AND event_type = 'camera_sample'
346
+ AND length(direction) = 3${range}${scene}${session}${cameraMode}
347
+ ) AS m
348
+ ON c.session_id = m.session_id AND c.ts >= m.ts
349
+ GROUP BY azimuth_bin, elevation_bin, mesh
350
+ ORDER BY count DESC
351
+ LIMIT ${limit}
352
+ `,
353
+ query_params: bag.values,
354
+ };
355
+ }
356
+ const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 0.5);
357
+ let originFilter = "";
358
+ if (opts.originVoxel != null) {
359
+ const ovx = bag.add("originVx", "f64", opts.originVoxel[0]);
360
+ const ovy = bag.add("originVy", "f64", opts.originVoxel[1]);
361
+ const ovz = bag.add("originVz", "f64", opts.originVoxel[2]);
362
+ originFilter = `
363
+ WHERE floor(j.ox / ${cellSize}) = ${ovx}
364
+ AND floor(j.oy / ${cellSize}) = ${ovy}
365
+ AND floor(j.oz / ${cellSize}) = ${ovz}`;
366
+ }
367
+ return {
368
+ query: `
369
+ SELECT
370
+ floor((atan2(j.dz, j.dx) + pi()) / (2 * pi()) * ${bins}) AS azimuth_bin,
371
+ floor((asin(j.dy / greatest(sqrt(j.dx * j.dx + j.dy * j.dy + j.dz * j.dz), 1e-6)) + pi() / 2) / pi() * ${bins}) AS elevation_bin,
372
+ floor(j.ox / ${cellSize}) AS origin_vx,
373
+ floor(j.oy / ${cellSize}) AS origin_vy,
374
+ floor(j.oz / ${cellSize}) AS origin_vz,
375
+ avg(j.ox) AS origin_x,
376
+ avg(j.oy) AS origin_y,
377
+ avg(j.oz) AS origin_z,
378
+ j.mesh AS mesh,
379
+ count() AS count
380
+ FROM (
381
+ SELECT
382
+ c.mesh AS mesh,
383
+ m.dx AS dx, m.dy AS dy, m.dz AS dz,
384
+ CASE WHEN c.has_ray THEN c.rox ELSE m.px END AS ox,
385
+ CASE WHEN c.has_ray THEN c.roy ELSE m.py END AS oy,
386
+ CASE WHEN c.has_ray THEN c.roz ELSE m.pz END AS oz
387
+ FROM (
388
+ SELECT session_id, ts, mesh,
389
+ length(ray_origin) = 3 AS has_ray,
390
+ ray_origin[1] AS rox, ray_origin[2] AS roy, ray_origin[3] AS roz
391
+ FROM events
392
+ WHERE project_id = ${pid}
393
+ AND event_type = 'pointer_click'
394
+ AND mesh != ''${range}${scene}${session}${cameraMode}
395
+ ) AS c
396
+ ${d.asofInnerJoin} (
397
+ SELECT session_id, ts,
398
+ position[1] AS px, position[2] AS py, position[3] AS pz,
399
+ direction[1] AS dx, direction[2] AS dy, direction[3] AS dz
400
+ FROM events
401
+ WHERE project_id = ${pid}
402
+ AND event_type = 'camera_sample'
403
+ AND length(direction) = 3
404
+ AND length(position) = 3${range}${scene}${session}${cameraMode}
405
+ ) AS m
406
+ ON c.session_id = m.session_id AND c.ts >= m.ts
407
+ ) AS j${originFilter}
408
+ GROUP BY azimuth_bin, elevation_bin, origin_vx, origin_vy, origin_vz, mesh
409
+ ORDER BY count DESC
410
+ LIMIT ${limit}
411
+ `,
412
+ query_params: bag.values,
413
+ };
414
+ }
415
+ /** Most-interacted meshes (from `mesh_interaction` and pointer hits). */
416
+ export function buildTopMeshes(projectId, opts, d) {
417
+ const bag = new ParamBag(d);
418
+ const pid = bag.add("projectId", "string", projectId);
419
+ const range = rangeClause(bag, opts);
420
+ const session = sessionClause(bag, opts);
421
+ const limit = bag.add("limit", "u32", opts.limit ?? 25);
422
+ return {
423
+ query: `
424
+ SELECT mesh, count() AS count
425
+ FROM events
426
+ WHERE project_id = ${pid} AND mesh != ''${range}${session}
427
+ GROUP BY mesh
428
+ ORDER BY count DESC
429
+ LIMIT ${limit}
430
+ `,
431
+ query_params: bag.values,
432
+ };
433
+ }
434
+ /**
435
+ * Per-object attention / dwell from `mesh_visibility` summaries (#37). Sums the
436
+ * bucketed on-screen and gaze-centred time per mesh and tracks the peak screen
437
+ * fraction, ranked by total dwell. The 3D analog of time-on-element.
438
+ */
439
+ export function buildMeshDwell(projectId, opts, d) {
440
+ const bag = new ParamBag(d);
441
+ const pid = bag.add("projectId", "string", projectId);
442
+ const range = rangeClause(bag, opts);
443
+ const scene = sceneClause(bag, opts);
444
+ const session = sessionClause(bag, opts);
445
+ const limit = bag.add("limit", "u32", opts.limit ?? 25);
446
+ return {
447
+ query: `
448
+ SELECT
449
+ mesh,
450
+ sum(visible_ms) AS visible_ms,
451
+ sum(centered_ms) AS centered_ms,
452
+ max(screen_fraction) AS max_screen_fraction,
453
+ count() AS samples
454
+ FROM events
455
+ WHERE project_id = ${pid} AND event_type = 'mesh_visibility' AND mesh != ''${range}${scene}${session}
456
+ GROUP BY mesh
457
+ ORDER BY visible_ms DESC
458
+ LIMIT ${limit}
459
+ `,
460
+ query_params: bag.values,
461
+ };
462
+ }
463
+ /**
464
+ * Dead-click rate (#46): of all `pointer_click` events, how many hit nothing
465
+ * (the hit-test missed, so `mesh` is empty / no `hitMesh`). A high dead-click
466
+ * rate is a 3D discoverability problem — users click where they expect something
467
+ * interactive and get no response. Reuses `inputSourceShape` filters (ADR 0011).
468
+ */
469
+ export function buildDeadClicks(projectId, opts, d) {
470
+ const bag = new ParamBag(d);
471
+ const pid = bag.add("projectId", "string", projectId);
472
+ const range = rangeClause(bag, opts);
473
+ const scene = sceneClause(bag, opts);
474
+ const source = sourceClause(bag, opts);
475
+ const session = sessionClause(bag, opts);
476
+ return {
477
+ query: `
478
+ SELECT
479
+ count() AS total_clicks,
480
+ sum(CASE WHEN mesh = '' THEN 1 ELSE 0 END) AS dead_clicks
481
+ FROM events
482
+ WHERE project_id = ${pid} AND event_type = 'pointer_click'${range}${scene}${source}${session}
483
+ `,
484
+ query_params: bag.values,
485
+ };
486
+ }
487
+ /**
488
+ * Rage clicks (#47): rapid repeated clicks on the same mesh — a frustration
489
+ * signal ("I keep clicking this and nothing happens"). Derived purely from the
490
+ * `pointer_click` stream: clicks are bucketed into fixed `interval`-second
491
+ * windows per `(session, mesh)`, and a bucket with at least `minRepeats` clicks
492
+ * is reported as a rage cluster, ranked by burst size. Only clicks that hit a
493
+ * mesh count here; rapid clicks on empty space are the dead-click signal (#46).
494
+ * Reuses `inputSourceShape` filters (ADR 0011).
495
+ */
496
+ export function buildRageClicks(projectId, opts, d) {
497
+ const bag = new ParamBag(d);
498
+ const pid = bag.add("projectId", "string", projectId);
499
+ const interval = bag.add("interval", "u32", opts.interval ?? 2);
500
+ const range = rangeClause(bag, opts);
501
+ const scene = sceneClause(bag, opts);
502
+ const source = sourceClause(bag, opts);
503
+ const session = sessionClause(bag, opts);
504
+ const minRepeats = bag.add("minRepeats", "u32", opts.minRepeats ?? 3);
505
+ const limit = bag.add("limit", "u32", opts.limit ?? 100);
506
+ return {
507
+ query: `
508
+ SELECT
509
+ session_id,
510
+ mesh,
511
+ ${d.timeBucketMs("ts", interval)} AS bucket,
512
+ count() AS clicks
513
+ FROM events
514
+ WHERE project_id = ${pid} AND event_type = 'pointer_click' AND mesh != ''${range}${scene}${source}${session}
515
+ GROUP BY session_id, mesh, bucket
516
+ HAVING count() >= ${minRepeats}
517
+ ORDER BY clicks DESC
518
+ LIMIT ${limit}
519
+ `,
520
+ query_params: bag.values,
521
+ };
522
+ }
523
+ /**
524
+ * Hover hesitation from `hover_dwell` summaries (#48): per mesh, the total time
525
+ * visitors lingered on an object *without clicking it*, the number of hesitation
526
+ * episodes, and the longest single hover. High dwell with few interactions flags
527
+ * objects that look interactive but aren't — or aren't obviously clickable. The
528
+ * connector emits one bucketed episode per hover (its `dwellMs` is stored in the
529
+ * shared `visible_ms` column). Reuses `inputSourceShape` filters (ADR 0011).
530
+ */
531
+ export function buildHoverDwell(projectId, opts, d) {
532
+ const bag = new ParamBag(d);
533
+ const pid = bag.add("projectId", "string", projectId);
534
+ const range = rangeClause(bag, opts);
535
+ const scene = sceneClause(bag, opts);
536
+ const source = sourceClause(bag, opts);
537
+ const session = sessionClause(bag, opts);
538
+ const limit = bag.add("limit", "u32", opts.limit ?? 25);
539
+ return {
540
+ query: `
541
+ SELECT
542
+ mesh,
543
+ sum(visible_ms) AS dwell_ms,
544
+ max(visible_ms) AS max_dwell_ms,
545
+ count() AS episodes
546
+ FROM events
547
+ WHERE project_id = ${pid} AND event_type = 'hover_dwell' AND mesh != ''${range}${scene}${source}${session}
548
+ GROUP BY mesh
549
+ ORDER BY dwell_ms DESC
550
+ LIMIT ${limit}
551
+ `,
552
+ query_params: bag.values,
553
+ };
554
+ }
555
+ /**
556
+ * Shader / pipeline compile stalls (#42, design §C): per phase, the number of
557
+ * compile hitches and their total / average / worst main-thread duration.
558
+ * Compilation is the #1 source of first-interaction jank, so this surfaces the
559
+ * felt cost that `frame_perf` averages away. The connector emits one
560
+ * `compile_stall` per compile; its `durationMs` is stored in the shared
561
+ * `visible_ms` column and its coarse `phase` label in the `name` column.
562
+ */
563
+ export function buildCompileStalls(projectId, opts, d) {
564
+ const bag = new ParamBag(d);
565
+ const pid = bag.add("projectId", "string", projectId);
566
+ const range = rangeClause(bag, opts);
567
+ const scene = sceneClause(bag, opts);
568
+ const session = sessionClause(bag, opts);
569
+ const limit = bag.add("limit", "u32", opts.limit ?? 25);
570
+ return {
571
+ query: `
572
+ SELECT
573
+ name AS phase,
574
+ count() AS stalls,
575
+ sum(visible_ms) AS total_ms,
576
+ avg(visible_ms) AS avg_ms,
577
+ max(visible_ms) AS max_ms
578
+ FROM events
579
+ WHERE project_id = ${pid} AND event_type = 'compile_stall'${range}${scene}${session}
580
+ GROUP BY name
581
+ ORDER BY total_ms DESC
582
+ LIMIT ${limit}
583
+ `,
584
+ query_params: bag.values,
585
+ };
586
+ }
587
+ /**
588
+ * GPU / memory footprint summary from `resource_sample` samples (#44, design §C).
589
+ * Reports the average and peak of each footprint metric over the range — the
590
+ * actual cost the scene asked of the device (vs. `session_start.device` caps).
591
+ * Unreported metrics are stored as `0`; `NULLIF(..., 0)` keeps those out of the
592
+ * averages so a metric one engine omits doesn't dilute another's.
593
+ */
594
+ export function buildResourceSummary(projectId, opts, d) {
595
+ const bag = new ParamBag(d);
596
+ const pid = bag.add("projectId", "string", projectId);
597
+ const range = rangeClause(bag, opts);
598
+ const session = sessionClause(bag, opts);
599
+ return {
600
+ query: `
601
+ SELECT
602
+ count() AS samples,
603
+ avg(NULLIF(js_heap_bytes, 0)) AS avg_js_heap_bytes,
604
+ max(js_heap_bytes) AS max_js_heap_bytes,
605
+ avg(NULLIF(triangles, 0)) AS avg_triangles,
606
+ max(triangles) AS max_triangles,
607
+ avg(NULLIF(vertices, 0)) AS avg_vertices,
608
+ max(vertices) AS max_vertices,
609
+ avg(NULLIF(texture_bytes, 0)) AS avg_texture_bytes,
610
+ max(texture_bytes) AS max_texture_bytes,
611
+ avg(NULLIF(geometry_bytes, 0)) AS avg_geometry_bytes,
612
+ max(geometry_bytes) AS max_geometry_bytes
613
+ FROM events
614
+ WHERE project_id = ${pid} AND event_type = 'resource_sample'${range}${session}
615
+ `,
616
+ query_params: bag.values,
617
+ };
618
+ }
619
+ /**
620
+ * GPU/memory footprint **percentiles**, computed per-session then aggregated
621
+ * (ADR 0028 §1), from `resource_sample`. Reports a typical (p50) and a peak
622
+ * (p95) value per session for JS heap, texture bytes, and triangle count, then
623
+ * summarizes each as the median across sessions — so a single heavy session does
624
+ * not set the headline footprint. Unreported metrics (stored `0`) are excluded
625
+ * via `nullIf`. Complements {@link buildResourceSummary}'s pooled avg/max with a
626
+ * distribution-honest view.
627
+ */
628
+ export function buildResourcePercentiles(projectId, opts, d) {
629
+ const bag = new ParamBag(d);
630
+ const pid = bag.add("projectId", "string", projectId);
631
+ const range = rangeClause(bag, opts);
632
+ const scene = sceneClause(bag, opts);
633
+ const session = sessionClause(bag, opts);
634
+ return {
635
+ query: `
636
+ SELECT
637
+ count() AS sessions,
638
+ sum(s_samples) AS samples,
639
+ ${d.quantile("s_heap_p50", 0.5)} AS p50_js_heap_bytes,
640
+ ${d.quantile("s_heap_p95", 0.5)} AS p95_js_heap_bytes,
641
+ ${d.quantile("s_tex_p50", 0.5)} AS p50_texture_bytes,
642
+ ${d.quantile("s_tex_p95", 0.5)} AS p95_texture_bytes,
643
+ ${d.quantile("s_tri_p50", 0.5)} AS p50_triangles,
644
+ ${d.quantile("s_tri_p95", 0.5)} AS p95_triangles
645
+ FROM (
646
+ SELECT
647
+ session_id,
648
+ count() AS s_samples,
649
+ ${d.quantile("nullIf(js_heap_bytes, 0)", 0.5)} AS s_heap_p50,
650
+ ${d.quantile("nullIf(js_heap_bytes, 0)", 0.95)} AS s_heap_p95,
651
+ ${d.quantile("nullIf(texture_bytes, 0)", 0.5)} AS s_tex_p50,
652
+ ${d.quantile("nullIf(texture_bytes, 0)", 0.95)} AS s_tex_p95,
653
+ ${d.quantile("nullIf(triangles, 0)", 0.5)} AS s_tri_p50,
654
+ ${d.quantile("nullIf(triangles, 0)", 0.95)} AS s_tri_p95
655
+ FROM events
656
+ WHERE project_id = ${pid} AND event_type = 'resource_sample'${range}${scene}${session}
657
+ GROUP BY session_id
658
+ ) per_session
659
+ `,
660
+ query_params: bag.values,
661
+ };
662
+ }
663
+ /**
664
+ * Stability-incident counts over the range: WebGL/WebGPU context losses
665
+ * (`context_lost`) and shader/pipeline compile stalls (`compile_stall`), plus the
666
+ * total incident count. These are the hard failures `frame_perf` cannot show — a
667
+ * context loss blanks the canvas, a compile stall freezes first interaction. An
668
+ * empty range reports `0` (not NULL) via `coalesce`.
669
+ */
670
+ export function buildStabilityCounts(projectId, opts, d) {
671
+ const bag = new ParamBag(d);
672
+ const pid = bag.add("projectId", "string", projectId);
673
+ const range = rangeClause(bag, opts);
674
+ const scene = sceneClause(bag, opts);
675
+ const session = sessionClause(bag, opts);
676
+ return {
677
+ query: `
678
+ SELECT
679
+ coalesce(sum(CASE WHEN event_type = 'context_lost' THEN 1 ELSE 0 END), 0) AS context_losses,
680
+ coalesce(sum(CASE WHEN event_type = 'compile_stall' THEN 1 ELSE 0 END), 0) AS compile_stalls,
681
+ count() AS incidents
682
+ FROM events
683
+ WHERE project_id = ${pid} AND event_type IN ('context_lost', 'compile_stall')${range}${scene}${session}
684
+ `,
685
+ query_params: bag.values,
686
+ };
687
+ }
688
+ /**
689
+ * Capability / fidelity transitions from `capability_change` (#49, design §E):
690
+ * per (kind, from, to), how many times the app reported that fallback or
691
+ * recovery. Explains perf / visual-fidelity variance across the user base (e.g.
692
+ * how many sessions fell back WebGPU→WebGL2). `kind` is carried by the shared
693
+ * `name` column; the capability tokens live in `cap_from` / `cap_to`.
694
+ */
695
+ export function buildCapabilityChanges(projectId, opts, d) {
696
+ const bag = new ParamBag(d);
697
+ const pid = bag.add("projectId", "string", projectId);
698
+ const range = rangeClause(bag, opts);
699
+ const scene = sceneClause(bag, opts);
700
+ const session = sessionClause(bag, opts);
701
+ const limit = bag.add("limit", "u32", opts.limit ?? 50);
702
+ return {
703
+ query: `
704
+ SELECT
705
+ name AS kind,
706
+ cap_from AS "from",
707
+ cap_to AS "to",
708
+ count() AS changes
709
+ FROM events
710
+ WHERE project_id = ${pid} AND event_type = 'capability_change'${range}${scene}${session}
711
+ GROUP BY name, cap_from, cap_to
712
+ ORDER BY changes DESC
713
+ LIMIT ${limit}
714
+ `,
715
+ query_params: bag.values,
716
+ };
717
+ }
718
+ /**
719
+ * Camera-navigation gesture breakdown from `camera_gesture` (ADR 0025): per
720
+ * gesture `kind` (orbit / pan / dolly / zoom / roll / fly / navigate), how often
721
+ * users moved the viewpoint and how long each gesture lasted. This separates
722
+ * deliberate navigation intent from object selection (a click that doesn't move
723
+ * the camera emits no gesture), revealing how an audience explores a scene. The
724
+ * gesture `kind` is carried by the shared `name` column and its `durationMs` by
725
+ * the shared `visible_ms` column.
726
+ */
727
+ export function buildCameraGestures(projectId, opts, d) {
728
+ const bag = new ParamBag(d);
729
+ const pid = bag.add("projectId", "string", projectId);
730
+ const range = rangeClause(bag, opts);
731
+ const scene = sceneClause(bag, opts);
732
+ const source = sourceClause(bag, opts);
733
+ const session = sessionClause(bag, opts);
734
+ const limit = bag.add("limit", "u32", opts.limit ?? 25);
735
+ return {
736
+ query: `
737
+ SELECT
738
+ name AS kind,
739
+ count() AS gestures,
740
+ sum(visible_ms) AS total_ms,
741
+ avg(visible_ms) AS avg_ms,
742
+ max(visible_ms) AS max_ms
743
+ FROM events
744
+ WHERE project_id = ${pid} AND event_type = 'camera_gesture'${range}${scene}${source}${session}
745
+ GROUP BY name
746
+ ORDER BY gestures DESC
747
+ LIMIT ${limit}
748
+ `,
749
+ query_params: bag.values,
750
+ };
751
+ }
752
+ /** Aggregate rendering-performance summary from `frame_perf` samples. */
753
+ export function buildPerfSummary(projectId, opts, d) {
754
+ const bag = new ParamBag(d);
755
+ const pid = bag.add("projectId", "string", projectId);
756
+ const range = rangeClause(bag, opts);
757
+ const session = sessionClause(bag, opts);
758
+ return {
759
+ query: `
760
+ SELECT
761
+ count() AS samples,
762
+ avg(fps) AS avg_fps,
763
+ min(fps) AS min_fps,
764
+ ${d.quantile("fps", 0.5)} AS p50_fps
765
+ FROM events
766
+ WHERE project_id = ${pid} AND event_type = 'frame_perf'${range}${session}
767
+ `,
768
+ query_params: bag.values,
769
+ };
770
+ }
771
+ /**
772
+ * FPS distribution, computed **per-session then aggregated** (ADR 0028 §1). Each
773
+ * session contributes its own p05/p50/p95 FPS, so neither long sessions nor
774
+ * high-frame-rate devices dominate the headline; the reported percentiles are
775
+ * the median across sessions of each per-session percentile ("median-of-medians").
776
+ * `sessions` is the number of contributing sessions and `samples` the total
777
+ * `frame_perf` sample count.
778
+ */
779
+ export function buildPerfDistribution(projectId, opts, d) {
780
+ const bag = new ParamBag(d);
781
+ const pid = bag.add("projectId", "string", projectId);
782
+ const range = rangeClause(bag, opts);
783
+ const scene = sceneClause(bag, opts);
784
+ const session = sessionClause(bag, opts);
785
+ return {
786
+ query: `
787
+ SELECT
788
+ count() AS sessions,
789
+ sum(s_samples) AS samples,
790
+ ${d.quantile("s_p05", 0.5)} AS p05_fps,
791
+ ${d.quantile("s_p50", 0.5)} AS p50_fps,
792
+ ${d.quantile("s_p95", 0.5)} AS p95_fps
793
+ FROM (
794
+ SELECT
795
+ session_id,
796
+ count() AS s_samples,
797
+ ${d.quantile("fps", 0.05)} AS s_p05,
798
+ ${d.quantile("fps", 0.5)} AS s_p50,
799
+ ${d.quantile("fps", 0.95)} AS s_p95
800
+ FROM events
801
+ WHERE project_id = ${pid} AND event_type = 'frame_perf'${range}${scene}${session}
802
+ GROUP BY session_id
803
+ ) per_session
804
+ `,
805
+ query_params: bag.values,
806
+ };
807
+ }
808
+ /**
809
+ * Histogram of **per-session median FPS** (ADR 0028 §1). One session contributes
810
+ * a single data point — its median FPS — bucketed into `bucket`-wide FPS bins
811
+ * (default 10). `bucket` is the inclusive lower bound of each bin and `sessions`
812
+ * the number of sessions whose median FPS falls in it. Plotting session medians
813
+ * rather than raw samples keeps the shape honest about how many *experiences*
814
+ * were smooth, instead of letting a few chatty sessions skew the curve.
815
+ */
816
+ export function buildFpsHistogram(projectId, opts, d) {
817
+ const bag = new ParamBag(d);
818
+ const pid = bag.add("projectId", "string", projectId);
819
+ const range = rangeClause(bag, opts);
820
+ const scene = sceneClause(bag, opts);
821
+ const session = sessionClause(bag, opts);
822
+ const bucket = bag.add("bucket", "u32", opts.bucket ?? 10);
823
+ return {
824
+ query: `
825
+ SELECT
826
+ floor(s_p50 / ${bucket}) * ${bucket} AS bucket,
827
+ count() AS sessions
828
+ FROM (
829
+ SELECT
830
+ session_id,
831
+ ${d.quantile("fps", 0.5)} AS s_p50
832
+ FROM events
833
+ WHERE project_id = ${pid} AND event_type = 'frame_perf'${range}${scene}${session}
834
+ GROUP BY session_id
835
+ ) per_session
836
+ GROUP BY bucket
837
+ ORDER BY bucket ASC
838
+ `,
839
+ query_params: bag.values,
840
+ };
841
+ }
842
+ /**
843
+ * Frame-time percentiles in milliseconds, computed **per-session then
844
+ * aggregated** (ADR 0028 §1). `p50_ms` is the median across sessions of each
845
+ * session's median `frame_time_ms` (the typical frame cost); `p95_ms` is the
846
+ * median across sessions of each session's worst-window `frame_time_p95_ms` —
847
+ * the SDK already reports a per-window p95, so the tail is read from that
848
+ * promoted column rather than re-derived from window means. Zero-valued detail
849
+ * (unreported by older samples / non-`frame_perf` rows) is ignored via `nullIf`.
850
+ */
851
+ export function buildFrameTimePercentiles(projectId, opts, d) {
852
+ const bag = new ParamBag(d);
853
+ const pid = bag.add("projectId", "string", projectId);
854
+ const range = rangeClause(bag, opts);
855
+ const scene = sceneClause(bag, opts);
856
+ const session = sessionClause(bag, opts);
857
+ return {
858
+ query: `
859
+ SELECT
860
+ count() AS sessions,
861
+ sum(s_samples) AS samples,
862
+ ${d.quantile("s_p50", 0.5)} AS p50_ms,
863
+ ${d.quantile("s_p95", 0.5)} AS p95_ms
864
+ FROM (
865
+ SELECT
866
+ session_id,
867
+ count() AS s_samples,
868
+ ${d.quantile("nullIf(frame_time_ms, 0)", 0.5)} AS s_p50,
869
+ max(nullIf(frame_time_p95_ms, 0)) AS s_p95
870
+ FROM events
871
+ WHERE project_id = ${pid} AND event_type = 'frame_perf'${range}${scene}${session}
872
+ GROUP BY session_id
873
+ ) per_session
874
+ `,
875
+ query_params: bag.values,
876
+ };
877
+ }
878
+ /**
879
+ * Jank rate, computed **per-session then aggregated** (ADR 0028 §1). Each
880
+ * session's rate is its total `long_frames` divided by its `frame_perf` sample
881
+ * windows; the headline is the median session rate plus the worst-decile (p90)
882
+ * session rate, so a handful of janky sessions surface instead of being averaged
883
+ * away. `total_long_frames` is the raw jank count and `sessions` the number of
884
+ * contributing sessions.
885
+ */
886
+ export function buildJankRate(projectId, opts, d) {
887
+ const bag = new ParamBag(d);
888
+ const pid = bag.add("projectId", "string", projectId);
889
+ const range = rangeClause(bag, opts);
890
+ const scene = sceneClause(bag, opts);
891
+ const session = sessionClause(bag, opts);
892
+ return {
893
+ query: `
894
+ SELECT
895
+ count() AS sessions,
896
+ sum(s_long) AS total_long_frames,
897
+ ${d.quantile("s_rate", 0.5)} AS median_rate,
898
+ ${d.quantile("s_rate", 0.9)} AS worst_decile_rate
899
+ FROM (
900
+ SELECT
901
+ session_id,
902
+ sum(long_frames) AS s_long,
903
+ sum(long_frames) * 1.0 / count() AS s_rate
904
+ FROM events
905
+ WHERE project_id = ${pid} AND event_type = 'frame_perf'${range}${scene}${session}
906
+ GROUP BY session_id
907
+ ) per_session
908
+ `,
909
+ query_params: bag.values,
910
+ };
911
+ }
912
+ /**
913
+ * FPS segmented by device class, computed **per-session then aggregated** (ADR
914
+ * 0028 §2). Each session's median FPS is attributed to the graphics backend,
915
+ * mobile flag, and GPU `renderer` recorded in its `session_start.device` block —
916
+ * data already on the wire, so there is no SDK or schema change. Device fields
917
+ * are read from the `session_start` payload JSON (they are not promoted columns)
918
+ * and `coalesce`d to `''` when a session never reported them. `p50_fps` is the
919
+ * median across sessions in the group of each session's median FPS.
920
+ */
921
+ export function buildPerfByDevice(projectId, opts, d) {
922
+ const bag = new ParamBag(d);
923
+ const pid = bag.add("projectId", "string", projectId);
924
+ const range = rangeClause(bag, opts);
925
+ const scene = sceneClause(bag, opts);
926
+ const session = sessionClause(bag, opts);
927
+ const engine = d.jsonText("payload", "device", "engine");
928
+ const isMobile = d.jsonText("payload", "device", "isMobile");
929
+ const renderer = d.jsonText("payload", "device", "renderer");
930
+ return {
931
+ query: `
932
+ WITH session_device AS (
933
+ SELECT
934
+ session_id,
935
+ ${engine} AS engine,
936
+ ${isMobile} AS is_mobile,
937
+ ${renderer} AS renderer
938
+ FROM events
939
+ WHERE project_id = ${pid} AND event_type = 'session_start'
940
+ ),
941
+ session_perf AS (
942
+ SELECT
943
+ session_id,
944
+ count() AS s_samples,
945
+ ${d.quantile("fps", 0.5)} AS s_p50
946
+ FROM events
947
+ WHERE project_id = ${pid} AND event_type = 'frame_perf'${range}${scene}${session}
948
+ GROUP BY session_id
949
+ )
950
+ SELECT
951
+ coalesce(dev.engine, '') AS engine,
952
+ coalesce(dev.is_mobile, '') AS is_mobile,
953
+ coalesce(dev.renderer, '') AS renderer,
954
+ count() AS sessions,
955
+ sum(perf.s_samples) AS samples,
956
+ ${d.quantile("perf.s_p50", 0.5)} AS p50_fps
957
+ FROM session_perf perf
958
+ LEFT JOIN session_device dev ON dev.session_id = perf.session_id
959
+ GROUP BY engine, is_mobile, renderer
960
+ ORDER BY sessions DESC
961
+ `,
962
+ query_params: bag.values,
963
+ };
964
+ }
965
+ /**
966
+ * FPS segmented by scene, computed **per-session then aggregated** (ADR 0028 §1).
967
+ * Each session's median FPS is attributed to its scene (a session renders one
968
+ * scene); `p50_fps` is the median across the scene's sessions of each session's
969
+ * median FPS, so neither long sessions nor a busy scene's traffic skews the
970
+ * comparison between scenes.
971
+ */
972
+ export function buildPerfByScene(projectId, opts, d) {
973
+ const bag = new ParamBag(d);
974
+ const pid = bag.add("projectId", "string", projectId);
975
+ const range = rangeClause(bag, opts);
976
+ const scene = sceneClause(bag, opts);
977
+ const session = sessionClause(bag, opts);
978
+ return {
979
+ query: `
980
+ SELECT
981
+ scene_id,
982
+ count() AS sessions,
983
+ sum(s_samples) AS samples,
984
+ ${d.quantile("s_p50", 0.5)} AS p50_fps
985
+ FROM (
986
+ SELECT
987
+ session_id,
988
+ ${d.anyValue("scene_id")} AS scene_id,
989
+ count() AS s_samples,
990
+ ${d.quantile("fps", 0.5)} AS s_p50
991
+ FROM events
992
+ WHERE project_id = ${pid} AND event_type = 'frame_perf'${range}${scene}${session}
993
+ GROUP BY session_id
994
+ ) per_session
995
+ GROUP BY scene_id
996
+ ORDER BY sessions DESC
997
+ `,
998
+ query_params: bag.values,
999
+ };
1000
+ }
1001
+ /**
1002
+ * Daily rendering-performance trend, read from the `perf_daily` materialized
1003
+ * view (migration 0003/0004). Aggregate states are merged with `-Merge`.
1004
+ */
1005
+ export function buildPerfDaily(projectId, opts, d) {
1006
+ const bag = new ParamBag(d);
1007
+ const pid = bag.add("projectId", "string", projectId);
1008
+ const range = dayRangeClause(bag, d, opts);
1009
+ const limit = bag.add("limit", "u32", opts.limit ?? 30);
1010
+ return {
1011
+ query: `
1012
+ SELECT
1013
+ ${d.toText("day")} AS day,
1014
+ ${d.countMerge("samples_state")} AS samples,
1015
+ ${d.avgMerge("avg_fps_state")} AS avg_fps,
1016
+ min(min_fps) AS min_fps,
1017
+ ${d.quantileMerge("p50_fps_state", 0.5)} AS p50_fps
1018
+ FROM perf_daily
1019
+ WHERE project_id = ${pid}${range}
1020
+ GROUP BY day
1021
+ ORDER BY day DESC
1022
+ LIMIT ${limit}
1023
+ `,
1024
+ query_params: bag.values,
1025
+ };
1026
+ }
1027
+ /**
1028
+ * Daily event-count trend per event type, read from the `events_daily`
1029
+ * materialized view (migration 0005/0006). SummingMergeTree counts are summed.
1030
+ */
1031
+ export function buildEventsDaily(projectId, opts, d) {
1032
+ const bag = new ParamBag(d);
1033
+ const pid = bag.add("projectId", "string", projectId);
1034
+ const range = dayRangeClause(bag, d, opts);
1035
+ const limit = bag.add("limit", "u32", opts.limit ?? 200);
1036
+ return {
1037
+ query: `
1038
+ SELECT
1039
+ ${d.toText("day")} AS day,
1040
+ event_type,
1041
+ sum(events) AS events
1042
+ FROM events_daily
1043
+ WHERE project_id = ${pid}${range}
1044
+ GROUP BY day, event_type
1045
+ ORDER BY day DESC, events DESC
1046
+ LIMIT ${limit}
1047
+ `,
1048
+ query_params: bag.values,
1049
+ };
1050
+ }
1051
+ /**
1052
+ * Distinct developer-assigned scenes for a project (ADR 0010), with an event
1053
+ * `count` and the most recent activity `last_seen`. Time-range aware.
1054
+ */
1055
+ export function buildDistinctScenes(projectId, opts, d) {
1056
+ const bag = new ParamBag(d);
1057
+ const pid = bag.add("projectId", "string", projectId);
1058
+ const range = rangeClause(bag, opts);
1059
+ const limit = bag.add("limit", "u32", opts.limit ?? 200);
1060
+ return {
1061
+ query: `
1062
+ SELECT
1063
+ scene_id,
1064
+ count() AS events,
1065
+ max(ts) AS last_seen
1066
+ FROM events
1067
+ WHERE project_id = ${pid}${range}
1068
+ GROUP BY scene_id
1069
+ ORDER BY events DESC
1070
+ LIMIT ${limit}
1071
+ `,
1072
+ query_params: bag.values,
1073
+ };
1074
+ }
1075
+ /**
1076
+ * Event-volume time-series: bucket events into fixed `interval`-second windows
1077
+ * and return the per-bucket count plus the average FPS of any `frame_perf`
1078
+ * samples in that bucket. Optionally scoped to one scene and/or event type.
1079
+ */
1080
+ export function buildTimeseries(projectId, opts, d) {
1081
+ const bag = new ParamBag(d);
1082
+ const pid = bag.add("projectId", "string", projectId);
1083
+ const interval = bag.add("interval", "u32", opts.interval ?? 3600);
1084
+ const range = rangeClause(bag, opts);
1085
+ const scene = sceneClause(bag, opts);
1086
+ const type = opts.type != null && opts.type.length > 0
1087
+ ? ` AND event_type = ${bag.add("type", "string", opts.type)}`
1088
+ : "";
1089
+ return {
1090
+ query: `
1091
+ SELECT
1092
+ ${d.timeBucketMs("ts", interval)} AS bucket,
1093
+ count() AS events,
1094
+ ${d.avgIf("fps", "event_type = 'frame_perf'")} AS avg_fps
1095
+ FROM events
1096
+ WHERE project_id = ${pid}${range}${scene}${type}
1097
+ GROUP BY bucket
1098
+ ORDER BY bucket ASC
1099
+ `,
1100
+ query_params: bag.values,
1101
+ };
1102
+ }
1103
+ /**
1104
+ * Per-event-type counts over the range (optionally one scene). Powers the scene
1105
+ * health panel — error rate, context-loss incidents, focus/visibility gaps, etc.
1106
+ */
1107
+ export function buildEventTypeCounts(projectId, opts, d) {
1108
+ const bag = new ParamBag(d);
1109
+ const pid = bag.add("projectId", "string", projectId);
1110
+ const range = rangeClause(bag, opts);
1111
+ const scene = sceneClause(bag, opts);
1112
+ return {
1113
+ query: `
1114
+ SELECT event_type, count() AS count
1115
+ FROM events
1116
+ WHERE project_id = ${pid}${range}${scene}
1117
+ GROUP BY event_type
1118
+ ORDER BY count DESC
1119
+ `,
1120
+ query_params: bag.values,
1121
+ };
1122
+ }
1123
+ /**
1124
+ * Scene coverage / dead zones (derived, ADR — scene-metrics §B): voxel-bin the
1125
+ * camera *position* of `camera_sample` into a uniform grid of `cellSize`-sized
1126
+ * cubes. Each row is an occupied voxel with its visit `count`. Exploration
1127
+ * completeness ("saw 40% of the scene") and never-visited regions are computed by
1128
+ * the consumer against the scene AABB voxel count — the AABB lives in the scene
1129
+ * registry, not the events table, so it is layered in at presentation time.
1130
+ */
1131
+ export function buildSceneCoverage(projectId, opts, d) {
1132
+ const bag = new ParamBag(d);
1133
+ const pid = bag.add("projectId", "string", projectId);
1134
+ const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 1);
1135
+ const range = rangeClause(bag, opts);
1136
+ const scene = sceneClause(bag, opts);
1137
+ const session = sessionClause(bag, opts);
1138
+ const limit = bag.add("limit", "u32", opts.limit ?? 5000);
1139
+ return {
1140
+ query: `
1141
+ SELECT
1142
+ floor(position[1] / ${cellSize}) AS vx,
1143
+ floor(position[2] / ${cellSize}) AS vy,
1144
+ floor(position[3] / ${cellSize}) AS vz,
1145
+ count() AS count
1146
+ FROM events
1147
+ WHERE project_id = ${pid}
1148
+ AND event_type = 'camera_sample'
1149
+ AND length(position) = 3${range}${scene}${session}
1150
+ GROUP BY vx, vy, vz
1151
+ ORDER BY count DESC
1152
+ LIMIT ${limit}
1153
+ `,
1154
+ query_params: bag.values,
1155
+ };
1156
+ }
1157
+ /**
1158
+ * Camera distance / zoom distribution (derived, scene-metrics §B): histogram the
1159
+ * distance from the camera *position* of each `camera_sample` to a reference
1160
+ * `center` (the scene-AABB center, passed in world units; defaults to the
1161
+ * origin), bucketed into `bucketSize`-wide bins. A proxy for engagement intensity
1162
+ * — how close visitors get to the subject.
1163
+ */
1164
+ export function buildCameraDistance(projectId, opts, d) {
1165
+ const bag = new ParamBag(d);
1166
+ const pid = bag.add("projectId", "string", projectId);
1167
+ const [cx0, cy0, cz0] = opts.center ?? [0, 0, 0];
1168
+ const cx = bag.add("centerX", "f64", cx0);
1169
+ const cy = bag.add("centerY", "f64", cy0);
1170
+ const cz = bag.add("centerZ", "f64", cz0);
1171
+ const bucketSize = bag.add("bucketSize", "f64", opts.bucketSize ?? 1);
1172
+ const range = rangeClause(bag, opts);
1173
+ const scene = sceneClause(bag, opts);
1174
+ const session = sessionClause(bag, opts);
1175
+ const limit = bag.add("limit", "u32", opts.limit ?? 1000);
1176
+ return {
1177
+ query: `
1178
+ SELECT
1179
+ floor(sqrt(
1180
+ (position[1] - ${cx}) * (position[1] - ${cx}) +
1181
+ (position[2] - ${cy}) * (position[2] - ${cy}) +
1182
+ (position[3] - ${cz}) * (position[3] - ${cz})
1183
+ ) / ${bucketSize}) AS bucket,
1184
+ count() AS count
1185
+ FROM events
1186
+ WHERE project_id = ${pid}
1187
+ AND event_type = 'camera_sample'
1188
+ AND length(position) = 3${range}${scene}${session}
1189
+ GROUP BY bucket
1190
+ ORDER BY bucket ASC
1191
+ LIMIT ${limit}
1192
+ `,
1193
+ query_params: bag.values,
1194
+ };
1195
+ }
1196
+ /**
1197
+ * Navigation effort / friction (derived, scene-metrics §B): per session, ASOF
1198
+ * self-join each `camera_sample` to the immediately preceding one and accumulate
1199
+ * the inter-sample travel distance. `total_distance` is the path length; segments
1200
+ * whose distance clears `moveThreshold` count as *active* (the rest are idle
1201
+ * dwell), so a high segment count with low active distance flags a "stuck" / lost
1202
+ * visitor. World units throughout.
1203
+ */
1204
+ export function buildNavigationStats(projectId, opts, d) {
1205
+ const bag = new ParamBag(d);
1206
+ const pid = bag.add("projectId", "string", projectId);
1207
+ const moveThreshold = bag.add("moveThreshold", "f64", opts.moveThreshold ?? 0.05);
1208
+ const range = rangeClause(bag, opts);
1209
+ const scene = sceneClause(bag, opts);
1210
+ const session = sessionClause(bag, opts);
1211
+ const limit = bag.add("limit", "u32", opts.limit ?? 500);
1212
+ const sampleSelect = `
1213
+ SELECT session_id, ts, position[1] AS px, position[2] AS py, position[3] AS pz
1214
+ FROM events
1215
+ WHERE project_id = ${pid}
1216
+ AND event_type = 'camera_sample'
1217
+ AND length(position) = 3${range}${scene}${session}`;
1218
+ return {
1219
+ query: `
1220
+ SELECT
1221
+ session_id,
1222
+ count() AS segments,
1223
+ sum(dist) AS total_distance,
1224
+ sum(CASE WHEN dist >= ${moveThreshold} THEN 1 ELSE 0 END) AS active_segments,
1225
+ sum(CASE WHEN dist >= ${moveThreshold} THEN dist ELSE 0 END) AS active_distance
1226
+ FROM (
1227
+ SELECT
1228
+ c.session_id AS session_id,
1229
+ sqrt(
1230
+ (c.px - m.px) * (c.px - m.px) +
1231
+ (c.py - m.py) * (c.py - m.py) +
1232
+ (c.pz - m.pz) * (c.pz - m.pz)
1233
+ ) AS dist
1234
+ FROM (${sampleSelect}
1235
+ ) AS c
1236
+ ${d.asofInnerJoin} (${sampleSelect}
1237
+ ) AS m
1238
+ ON c.session_id = m.session_id AND c.ts > m.ts
1239
+ ) AS seg
1240
+ GROUP BY session_id
1241
+ ORDER BY total_distance DESC
1242
+ LIMIT ${limit}
1243
+ `,
1244
+ query_params: bag.values,
1245
+ };
1246
+ }
1247
+ /** XR input sources that distinguish hand-tracking, controllers and gaze. */
1248
+ const XR_SOURCES = "('xr-controller', 'hand', 'gaze', 'transient')";
1249
+ /**
1250
+ * Event types that carry the shared input-source vocabulary (ADR 0011) — i.e.
1251
+ * the interactions actually *triggered by* an input source. Restricting to these
1252
+ * keeps non-interaction events (`camera_sample`, `frame_perf`, …), whose `source`
1253
+ * column is the realized `'mouse'` default, out of the source breakdown.
1254
+ */
1255
+ const INPUT_SOURCE_EVENT_TYPES = "('pointer_move', 'pointer_click', 'pointer_down', 'pointer_up', " +
1256
+ "'mesh_interaction', 'hover_dwell', 'camera_gesture', 'input_action')";
1257
+ /**
1258
+ * Input-source breakdown (ADR 0011): for every interaction event that carries an
1259
+ * input source, how many fired per `(event_type, source)`, and how many distinct
1260
+ * sessions used that pairing. This turns `source` from a filter-only dimension
1261
+ * into an actual insight — e.g. how many `mesh_interaction`s came from an
1262
+ * `xr-controller` vs a `mouse`, or whether `pointer_click`s skew to `touch`.
1263
+ * Honors the same scene/source/session filters as the rest of the surface.
1264
+ */
1265
+ export function buildInteractionsBySource(projectId, opts, d) {
1266
+ const bag = new ParamBag(d);
1267
+ const pid = bag.add("projectId", "string", projectId);
1268
+ const range = rangeClause(bag, opts);
1269
+ const scene = sceneClause(bag, opts);
1270
+ const source = sourceClause(bag, opts);
1271
+ const session = sessionClause(bag, opts);
1272
+ const limit = bag.add("limit", "u32", opts.limit ?? 100);
1273
+ return {
1274
+ query: `
1275
+ SELECT
1276
+ event_type,
1277
+ source,
1278
+ count() AS count,
1279
+ count(DISTINCT session_id) AS sessions
1280
+ FROM events
1281
+ WHERE project_id = ${pid}
1282
+ AND event_type IN ${INPUT_SOURCE_EVENT_TYPES}${range}${scene}${source}${session}
1283
+ GROUP BY event_type, source
1284
+ ORDER BY count DESC
1285
+ LIMIT ${limit}
1286
+ `,
1287
+ query_params: bag.values,
1288
+ };
1289
+ }
1290
+ /**
1291
+ * XR motion-sickness proxy (derived, scene-metrics §F): per session, the
1292
+ * head/view rotation *rate* over the `camera_sample` pose stream. Each sample is
1293
+ * ASOF self-joined to its immediate predecessor and the angle between the two
1294
+ * (normalized) view directions is accumulated — `total_turn_rad` is the angular
1295
+ * path, `max_turn_rad` the worst single jerk, and `rapid_segments` counts steps
1296
+ * whose turn clears `rapidTurn` (rad). The pose cadence is fixed by the sampling
1297
+ * profile, so the per-sample angular delta is a discomfort proxy: rapid view
1298
+ * rotation correlates with simulator sickness, most acutely in a headset.
1299
+ */
1300
+ export function buildXrRotationRate(projectId, opts, d) {
1301
+ const bag = new ParamBag(d);
1302
+ const pid = bag.add("projectId", "string", projectId);
1303
+ const rapidTurn = bag.add("rapidTurn", "f64", opts.rapidTurn ?? 0.5);
1304
+ const range = rangeClause(bag, opts);
1305
+ const scene = sceneClause(bag, opts);
1306
+ const session = sessionClause(bag, opts);
1307
+ const limit = bag.add("limit", "u32", opts.limit ?? 500);
1308
+ const sampleSelect = `
1309
+ SELECT session_id, ts, direction[1] AS dx, direction[2] AS dy, direction[3] AS dz
1310
+ FROM events
1311
+ WHERE project_id = ${pid}
1312
+ AND event_type = 'camera_sample'
1313
+ AND length(direction) = 3${range}${scene}${session}`;
1314
+ return {
1315
+ query: `
1316
+ SELECT
1317
+ session_id,
1318
+ count() AS samples,
1319
+ avg(turn) AS avg_turn_rad,
1320
+ max(turn) AS max_turn_rad,
1321
+ sum(turn) AS total_turn_rad,
1322
+ sum(CASE WHEN turn >= ${rapidTurn} THEN 1 ELSE 0 END) AS rapid_segments
1323
+ FROM (
1324
+ SELECT
1325
+ c.session_id AS session_id,
1326
+ acos(least(1, greatest(-1,
1327
+ (c.dx * m.dx + c.dy * m.dy + c.dz * m.dz) /
1328
+ (sqrt(c.dx * c.dx + c.dy * c.dy + c.dz * c.dz) *
1329
+ sqrt(m.dx * m.dx + m.dy * m.dy + m.dz * m.dz))
1330
+ ))) AS turn
1331
+ FROM (${sampleSelect}
1332
+ ) AS c
1333
+ ${d.asofInnerJoin} (${sampleSelect}
1334
+ ) AS m
1335
+ ON c.session_id = m.session_id AND c.ts > m.ts
1336
+ ) AS seg
1337
+ GROUP BY session_id
1338
+ ORDER BY total_turn_rad DESC
1339
+ LIMIT ${limit}
1340
+ `,
1341
+ query_params: bag.values,
1342
+ };
1343
+ }
1344
+ /**
1345
+ * XR input-source usage (derived, scene-metrics §F): hand vs. controller (vs.
1346
+ * gaze / transient) split, read from `source` on the existing interaction events.
1347
+ * One row per XR `source` with its interaction `count` and the number of
1348
+ * `sessions` that used it — flat-screen sources (`mouse`, `touch`, …) are
1349
+ * excluded so the breakdown is purely the immersive input mix.
1350
+ */
1351
+ export function buildXrSourceUsage(projectId, opts, d) {
1352
+ const bag = new ParamBag(d);
1353
+ const pid = bag.add("projectId", "string", projectId);
1354
+ const range = rangeClause(bag, opts);
1355
+ const scene = sceneClause(bag, opts);
1356
+ const session = sessionClause(bag, opts);
1357
+ const limit = bag.add("limit", "u32", opts.limit ?? 50);
1358
+ return {
1359
+ query: `
1360
+ SELECT
1361
+ source,
1362
+ count() AS interactions,
1363
+ count(DISTINCT session_id) AS sessions
1364
+ FROM events
1365
+ WHERE project_id = ${pid}
1366
+ AND source IN ${XR_SOURCES}${range}${scene}${session}
1367
+ GROUP BY source
1368
+ ORDER BY interactions DESC
1369
+ LIMIT ${limit}
1370
+ `,
1371
+ query_params: bag.values,
1372
+ };
1373
+ }
1374
+ /**
1375
+ * XR session abandonment (derived, scene-metrics §F): for every session that
1376
+ * used an XR input source, its first/last timestamps and event/interaction
1377
+ * counts. Comfort drop-off is read by the consumer as a short
1378
+ * `ended_at - started_at` span (a headset session cut short) — the wall-clock
1379
+ * bounds are engine-specific and excluded from parity, while the counts are
1380
+ * compared. Sessions with no XR input are omitted entirely.
1381
+ */
1382
+ export function buildXrAbandonment(projectId, opts, d) {
1383
+ const bag = new ParamBag(d);
1384
+ const pid = bag.add("projectId", "string", projectId);
1385
+ const range = rangeClause(bag, opts);
1386
+ const scene = sceneClause(bag, opts);
1387
+ const session = sessionClause(bag, opts);
1388
+ const limit = bag.add("limit", "u32", opts.limit ?? 500);
1389
+ return {
1390
+ query: `
1391
+ SELECT
1392
+ session_id,
1393
+ count() AS events,
1394
+ sum(CASE WHEN source IN ${XR_SOURCES} THEN 1 ELSE 0 END) AS xr_interactions,
1395
+ min(ts) AS started_at,
1396
+ max(ts) AS ended_at
1397
+ FROM events
1398
+ WHERE project_id = ${pid}${range}${scene}${session}
1399
+ AND session_id IN (
1400
+ SELECT session_id
1401
+ FROM events
1402
+ WHERE project_id = ${pid}
1403
+ AND source IN ${XR_SOURCES}
1404
+ )
1405
+ GROUP BY session_id
1406
+ ORDER BY started_at DESC
1407
+ LIMIT ${limit}
1408
+ `,
1409
+ query_params: bag.values,
1410
+ };
1411
+ }
1412
+ //# sourceMappingURL=aggregations.js.map