@gscdump/engine 0.32.12 → 0.33.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.
@@ -6,6 +6,20 @@ import { compileLogicalQueryPlan, substituteNamedFiles } from "./parquet-plan.mj
6
6
  import { sqlEscape } from "../sql-bind.mjs";
7
7
  import { buildLogicalPlan } from "gscdump/query/plan";
8
8
  import { normalizeUrl } from "gscdump/normalize";
9
+ const BUFFER_READ_BATCH_SIZE = 8;
10
+ async function registerBufferedFiles(db, files, dataSource, registered, signal) {
11
+ for (let i = 0; i < files.length; i += BUFFER_READ_BATCH_SIZE) {
12
+ signal?.throwIfAborted();
13
+ const batch = files.slice(i, i + BUFFER_READ_BATCH_SIZE);
14
+ const buffers = await Promise.all(batch.map((file) => dataSource.read(file.key, void 0, signal)));
15
+ for (let j = 0; j < batch.length; j++) {
16
+ signal?.throwIfAborted();
17
+ const file = batch[j];
18
+ await db.registerFileBuffer(file.name, buffers[j]);
19
+ registered.push(file.name);
20
+ }
21
+ }
22
+ }
9
23
  async function encodeBytes(db, table, rows) {
10
24
  const inName = db.makeTempPath("json");
11
25
  const outName = db.makeTempPath("parquet");
@@ -72,17 +86,19 @@ function createDuckDBCodec(factory) {
72
86
  await db.dropFiles([outName]);
73
87
  }
74
88
  }
75
- const inputs = await Promise.all(inputKeys.map((k) => dataSource.read(k)));
76
89
  const inNames = [];
77
90
  const outName = db.makeTempPath("parquet");
78
91
  const registered = [];
79
- for (let i = 0; i < inputs.length; i++) {
92
+ const bufferedInputs = inputKeys.map((key) => {
80
93
  const name = db.makeTempPath("parquet");
81
- await db.registerFileBuffer(name, inputs[i]);
82
94
  inNames.push(name);
83
- registered.push(name);
84
- }
95
+ return {
96
+ key,
97
+ name
98
+ };
99
+ });
85
100
  try {
101
+ await registerBufferedFiles(db, bufferedInputs, dataSource, registered);
86
102
  const fileList = inNames.map((n) => `'${sqlEscape(n)}'`).join(", ");
87
103
  await db.query(`COPY (${dedupedMergeSql(ctx.table, fileList)}) TO '${sqlEscape(outName)}' (FORMAT PARQUET, COMPRESSION ZSTD)`);
88
104
  registered.push(outName);
@@ -127,23 +143,26 @@ function createDuckDBExecutor(factory) {
127
143
  const registered = [];
128
144
  const totalFiles = Object.values(fileKeys).reduce((n, keys) => n + keys.length, 0);
129
145
  const endRegister = profiler?.start("files.register", { files: totalFiles });
130
- await Promise.all(Object.entries(fileKeys).map(async ([name, keys]) => {
131
- const uris = keys.map((key) => dataSource.uri?.(key));
132
- const buffers = await Promise.all(keys.map((key, i) => uris[i] !== void 0 ? Promise.resolve(void 0) : dataSource.read(key, void 0, signal)));
133
- const resolved = [];
134
- for (let i = 0; i < keys.length; i++) {
135
- const uri = uris[i];
136
- if (uri !== void 0) resolved.push(uri);
137
- else {
138
- await db.registerFileBuffer(keys[i], buffers[i]);
139
- registered.push(keys[i]);
140
- resolved.push(keys[i]);
141
- }
142
- }
143
- placeholders[name] = resolved;
144
- }));
145
- endRegister?.({ buffered: registered.length });
146
146
  try {
147
+ await Promise.all(Object.entries(fileKeys).map(async ([name, keys]) => {
148
+ const resolved = [];
149
+ const buffered = [];
150
+ for (let i = 0; i < keys.length; i++) {
151
+ const key = keys[i];
152
+ const uri = dataSource.uri?.(key);
153
+ if (uri !== void 0) resolved.push(uri);
154
+ else {
155
+ buffered.push({
156
+ key,
157
+ name: key
158
+ });
159
+ resolved.push(key);
160
+ }
161
+ }
162
+ await registerBufferedFiles(db, buffered, dataSource, registered, signal);
163
+ placeholders[name] = resolved;
164
+ }));
165
+ endRegister?.({ buffered: registered.length });
147
166
  signal?.throwIfAborted();
148
167
  const finalSql = substituteNamedFiles(rewriteEmptyFileSets(sql, placeholders, table, placeholderTables), placeholders);
149
168
  const endQuery = profiler?.start("query.run");
@@ -274,7 +293,11 @@ async function gcOrphansImpl(deps, now, graceMs, opts = {}) {
274
293
  }
275
294
  return { deleted: retired.length + sweptOrphans + hourlyDeleted };
276
295
  }
277
- const PUSHABLE_COLUMN = { query: "query" };
296
+ const PUSHABLE_COLUMN = {
297
+ country: "country",
298
+ query: "query",
299
+ searchAppearance: "searchAppearance"
300
+ };
278
301
  function txLeaf(leaf, columns) {
279
302
  if (leaf.operator !== "equals") return null;
280
303
  const column = PUSHABLE_COLUMN[leaf.dimension];
@@ -15,11 +15,25 @@ declare const pgResolverAdapter: ResolverAdapter<PgTableKey>;
15
15
  */
16
16
  interface ResolverAdapterOptions {
17
17
  /**
18
- * Opt-in canonical-primary correctness: fold NULL/'' `query_canonical` back
19
- * to the raw `query` so canonical is a total GROUP BY / join key. Default
20
- * false preserves the legacy raw-column behaviour. See ADR-0018.
18
+ * Deprecated compatibility flag. Fact tables no longer carry
19
+ * `query_canonical`; canonical reads derive from `query_dim`, or from a
20
+ * canonical rollup relation when `queryCanonicalSource: 'column'` is set.
21
21
  */
22
22
  canonicalFallback?: boolean;
23
+ /**
24
+ * `queryDim` reads canonical from a joined query dimension. `column` is only
25
+ * for derived canonical rollup relations whose primary relation already
26
+ * carries a null-free `query_canonical` output column.
27
+ */
28
+ queryCanonicalSource?: 'queryDim' | 'column';
29
+ }
30
+ interface R2SqlResolverAdapterOptions extends ResolverAdapterOptions {
31
+ /**
32
+ * R2 SQL string partition equality can undercount on identity partitions;
33
+ * string-encoded catalogs use CONCAT(col, '') in partition predicates. Int
34
+ * catalogs do not need the workaround and keep bare equality for pruning.
35
+ */
36
+ partitionKeyEncoding?: 'string' | 'int';
23
37
  }
24
38
  declare function createParquetResolverAdapter(options?: ResolverAdapterOptions): ResolverAdapter<PgTableKey>;
25
39
  /**
@@ -33,4 +47,16 @@ declare function createParquetResolverAdapter(options?: ResolverAdapterOptions):
33
47
  * `${namespace}.pages`) before sending to R2 SQL.
34
48
  */
35
49
  declare function createIcebergResolverAdapter(options?: ResolverAdapterOptions): ResolverAdapter<PgTableKey>;
36
- export { PgTableKey, ResolverAdapterOptions, createIcebergResolverAdapter, createParquetResolverAdapter, pgResolverAdapter };
50
+ /**
51
+ * R2 SQL adapter for the Iceberg fact tables.
52
+ *
53
+ * It shares the multi-tenant Iceberg schema with `createIcebergResolverAdapter`
54
+ * but models R2 SQL's narrower execution surface: no window-total plans and no
55
+ * comparison joins. Int-partition catalogs are the default and emit bare
56
+ * equality predicates for pruning. Legacy string-partition catalogs must pass
57
+ * `partitionKeyEncoding: 'string'` to emit `CONCAT(partition_col, '') = ?`,
58
+ * working around R2 SQL's partition-string equality undercount while preserving
59
+ * bound params.
60
+ */
61
+ declare function createR2SqlResolverAdapter(options?: R2SqlResolverAdapterOptions): ResolverAdapter<PgTableKey>;
62
+ export { PgTableKey, R2SqlResolverAdapterOptions, ResolverAdapterOptions, createIcebergResolverAdapter, createParquetResolverAdapter, createR2SqlResolverAdapter, pgResolverAdapter };
@@ -2,6 +2,7 @@ import { SCHEMAS, drizzleSchema } from "./schema.mjs";
2
2
  import { enumeratePartitions } from "./compaction.mjs";
3
3
  import { escapeLike } from "../sql-fragments.mjs";
4
4
  import "../planner.mjs";
5
+ import "./schema2.mjs";
5
6
  import { UnresolvableDatasetError, buildLogicalComparisonPlan, buildLogicalPlan, inferDataset as inferLogicalDataset, isDatasetResolvable } from "gscdump/query/plan";
6
7
  import { PgDialect, pgTable, varchar } from "drizzle-orm/pg-core";
7
8
  import { normalizeUrl } from "gscdump/normalize";
@@ -9,7 +10,7 @@ import { sql } from "drizzle-orm";
9
10
  const DIMENSION_SURFACES = {
10
11
  page: ["api", "stored"],
11
12
  query: ["api", "stored"],
12
- queryCanonical: ["stored", "derived"],
13
+ queryCanonical: ["derived"],
13
14
  country: ["api", "stored"],
14
15
  device: ["api", "stored"],
15
16
  searchAppearance: ["api", "stored"],
@@ -34,7 +35,7 @@ const LOGICAL_DATASETS = {
34
35
  },
35
36
  queryCanonical: {
36
37
  column: "query_canonical",
37
- surfaces: ["stored", "derived"]
38
+ surfaces: ["derived"]
38
39
  },
39
40
  date: {
40
41
  column: "date",
@@ -52,7 +53,7 @@ const LOGICAL_DATASETS = {
52
53
  },
53
54
  queryCanonical: {
54
55
  column: "query_canonical",
55
- surfaces: ["stored", "derived"]
56
+ surfaces: ["derived"]
56
57
  },
57
58
  date: {
58
59
  column: "date",
@@ -108,7 +109,7 @@ const LOGICAL_DATASETS = {
108
109
  },
109
110
  queryCanonical: {
110
111
  column: "query_canonical",
111
- surfaces: ["stored", "derived"]
112
+ surfaces: ["derived"]
112
113
  },
113
114
  date: {
114
115
  column: "date",
@@ -130,7 +131,7 @@ const LOGICAL_DATASETS = {
130
131
  },
131
132
  queryCanonical: {
132
133
  column: "query_canonical",
133
- surfaces: ["stored", "derived"]
134
+ surfaces: ["derived"]
134
135
  },
135
136
  date: {
136
137
  column: "date",
@@ -169,6 +170,13 @@ const METRIC_NAMES = [
169
170
  "ctr",
170
171
  "position"
171
172
  ];
173
+ const QUERY_DIM_ALIAS = "query_dim";
174
+ function quoteIdent(id) {
175
+ return `"${id.replace(/"/g, "\"\"")}"`;
176
+ }
177
+ function qualifiedRaw(alias, column) {
178
+ return sql.raw(`${quoteIdent(alias)}.${quoteIdent(column)}`);
179
+ }
172
180
  function defaultSqliteUrlToPathExpr(col) {
173
181
  return `CASE WHEN ${col} LIKE 'http%' THEN CASE WHEN INSTR(SUBSTR(${col}, INSTR(${col}, '://') + 3), '/') > 0 THEN SUBSTR(${col}, INSTR(${col}, '://') + 2 + INSTR(SUBSTR(${col}, INSTR(${col}, '://') + 3), '/')) ELSE '/' END ELSE ${col} END`;
174
182
  }
@@ -180,7 +188,7 @@ function buildDimensionColumnMap(datasetToTableKey) {
180
188
  return Object.fromEntries(entries);
181
189
  }
182
190
  function createSqlFragments(config) {
183
- const { schema, datasetToTableKey, metricCast, regexPredicate, tableLabel, includeSiteId, includeSearchType, urlToPathExpr: urlToPathExprOverride, tableRef: tableRefOverride, canonicalFallback = false } = config;
191
+ const { schema, datasetToTableKey, metricCast, regexPredicate, tableLabel, includeSiteId, includeSearchType, urlToPathExpr: urlToPathExprOverride, tableRef: tableRefOverride, queryCanonicalSource = "queryDim", queryDimTableRef, queryDimSiteScoped = false } = config;
184
192
  const DIM_COLUMN_MAP = buildDimensionColumnMap(datasetToTableKey);
185
193
  function isMetricDimension(dim) {
186
194
  return METRIC_NAMES.includes(dim);
@@ -205,6 +213,11 @@ function createSqlFragments(config) {
205
213
  if (tableRefOverride) return tableRefOverride(tableKey);
206
214
  return sql`${schema[tableKey]}`;
207
215
  }
216
+ function fromSql(tableKey, options = {}) {
217
+ const base = tableRef(tableKey);
218
+ if (!options.queryCanonical || queryCanonicalSource !== "queryDim") return base;
219
+ return sql`${base} LEFT JOIN ${queryDimTableRef?.() ?? sql.raw(`${quoteIdent(QUERY_DIM_ALIAS)}`)} ON ${queryDimSiteScoped ? sql`${colRef(tableKey, "query")} = ${qualifiedRaw(QUERY_DIM_ALIAS, "query")} AND ${colRef(tableKey, "site_id")} = ${qualifiedRaw(QUERY_DIM_ALIAS, "site_id")}` : sql`${colRef(tableKey, "query")} = ${qualifiedRaw(QUERY_DIM_ALIAS, "query")}`}`;
220
+ }
208
221
  function dateColRef(tableKey) {
209
222
  return colRef(tableKey, "date");
210
223
  }
@@ -217,7 +230,10 @@ function createSqlFragments(config) {
217
230
  function dimExprSql(dim, tableKey) {
218
231
  const colName = dimColumn(dim, tableKey);
219
232
  if (dim === "page") return sql.raw(urlToPathExpr(colName));
220
- if (canonicalFallback && dim === "queryCanonical") return sql`COALESCE(NULLIF(${colRef(tableKey, colName)}, ''), ${colRef(tableKey, "query")})`;
233
+ if (dim === "queryCanonical") {
234
+ if (queryCanonicalSource === "queryDim") return sql`COALESCE(${qualifiedRaw(QUERY_DIM_ALIAS, "query_canonical")}, ${colRef(tableKey, "query")})`;
235
+ return qualifiedRaw(String(tableKey), "query_canonical");
236
+ }
221
237
  return colRef(tableKey, colName);
222
238
  }
223
239
  function metricSql(metric, tableKey) {
@@ -298,7 +314,7 @@ function createSqlFragments(config) {
298
314
  if (f.dimension === "date") continue;
299
315
  if (f.operator === "topLevel") continue;
300
316
  const dim = f.dimension;
301
- const cRef = colRef(tableKey, dimColumn(dim, tableKey));
317
+ const cRef = dim === "queryCanonical" ? void 0 : colRef(tableKey, dimColumn(dim, tableKey));
302
318
  const matchExpr = dim === "page" || dim === "queryCanonical" ? dimExprSql(dim, tableKey) : cRef;
303
319
  const patternExpr = dim === "queryCanonical" ? matchExpr : cRef;
304
320
  switch (f.operator) {
@@ -339,6 +355,7 @@ function createSqlFragments(config) {
339
355
  urlToPathExpr,
340
356
  colRef,
341
357
  tableRef,
358
+ fromSql,
342
359
  dateColRef,
343
360
  siteIdColRef: includeSiteId ? siteIdColRef : void 0,
344
361
  searchTypeColRef: includeSearchType ? searchTypeColRef : void 0,
@@ -361,6 +378,7 @@ function createResolverAdapter(config) {
361
378
  dimColumn: runtime.dimColumn,
362
379
  isMetricDimension: runtime.isMetricDimension,
363
380
  tableRef: runtime.tableRef,
381
+ fromSql: runtime.fromSql,
364
382
  dateColRef: runtime.dateColRef,
365
383
  urlToPathExpr: runtime.urlToPathExpr,
366
384
  siteIdColRef: runtime.siteIdColRef,
@@ -439,7 +457,9 @@ function createParquetResolverAdapter(options = {}) {
439
457
  ...PG_BASE_CONFIG,
440
458
  tableLabel: "parquet-resolver-adapter",
441
459
  canonicalFallback: options.canonicalFallback ?? false,
442
- tableRef: (tk) => sql.raw(`read_parquet({{FILES}}, union_by_name = true) AS "${tk}"`)
460
+ queryCanonicalSource: options.queryCanonicalSource ?? "queryDim",
461
+ tableRef: (tk) => sql.raw(`read_parquet({{FILES}}, union_by_name = true) AS "${tk}"`),
462
+ queryDimTableRef: () => sql.raw("read_parquet({{QUERY_DIM}}, union_by_name = true) AS \"query_dim\"")
443
463
  });
444
464
  }
445
465
  function createIcebergResolverAdapter(options = {}) {
@@ -450,9 +470,35 @@ function createIcebergResolverAdapter(options = {}) {
450
470
  includeSearchType: true,
451
471
  tableLabel: "iceberg-resolver-adapter",
452
472
  canonicalFallback: options.canonicalFallback ?? false,
453
- tableRef: (tk) => sql.raw(`"${tk}"`)
473
+ queryCanonicalSource: options.queryCanonicalSource ?? "queryDim",
474
+ tableRef: (tk) => sql.raw(`"${tk}"`),
475
+ queryDimTableRef: () => sql.raw("\"query_dim\"")
454
476
  });
455
477
  }
478
+ function createR2SqlResolverAdapter(options = {}) {
479
+ const adapter = createResolverAdapter({
480
+ ...PG_BASE_CONFIG,
481
+ schema: icebergSchema,
482
+ includeSiteId: true,
483
+ includeSearchType: true,
484
+ tableLabel: "r2-sql-resolver-adapter",
485
+ canonicalFallback: options.canonicalFallback ?? false,
486
+ queryCanonicalSource: options.queryCanonicalSource ?? "queryDim",
487
+ capabilities: {
488
+ regex: false,
489
+ comparisonJoin: false,
490
+ windowTotals: false
491
+ },
492
+ tableRef: (tk) => sql.raw(`"${tk}"`),
493
+ queryDimTableRef: () => sql.raw("\"query_dim\"")
494
+ });
495
+ if ((options.partitionKeyEncoding ?? "int") === "int") return adapter;
496
+ return {
497
+ ...adapter,
498
+ siteIdColRef: (tk) => sql`CONCAT(${adapter.siteIdColRef(tk)}, '')`,
499
+ searchTypeColRef: (tk) => sql`CONCAT(${adapter.searchTypeColRef(tk)}, '')`
500
+ };
501
+ }
456
502
  const ALLOWED_FILTER_DIMS = /* @__PURE__ */ new Set(["date", "queryCanonical"]);
457
503
  function planCoveredByCanonicalRollup(plan) {
458
504
  if (plan.dataset !== "queries") return false;
@@ -548,6 +594,7 @@ function buildScope(state, options) {
548
594
  const groupByDims = plan.groupByDimensions;
549
595
  const hasDate = plan.hasDate;
550
596
  const metrics = plan.metrics;
597
+ const queryCanonicalUsed = groupByDims.includes("queryCanonical") || plan.dimensionFilters.some((filter) => filter.dimension === "queryCanonical");
551
598
  const wherePredicates = [];
552
599
  if (adapter.siteIdColRef && siteId != null) wherePredicates.push(sql`${adapter.siteIdColRef(tableKey)} = ${siteId}`);
553
600
  if (adapter.searchTypeColRef && searchType != null) wherePredicates.push(sql`${adapter.searchTypeColRef(tableKey)} = ${searchType}`);
@@ -569,7 +616,8 @@ function buildScope(state, options) {
569
616
  having: adapter.havingPredicates(metricFilters, tableKey),
570
617
  dimFilters,
571
618
  startDate: plan.dateRange.startDate,
572
- endDate: plan.dateRange.endDate
619
+ endDate: plan.dateRange.endDate,
620
+ queryCanonicalUsed
573
621
  };
574
622
  }
575
623
  function buildComparisonPlan(current, previous, capabilities) {
@@ -584,8 +632,8 @@ function compileCollapsed(adapter, q) {
584
632
  }
585
633
  function resolveToSQLOptimized(state, options) {
586
634
  const { adapter } = options;
587
- const { tableKey, groupByDims, hasDate, metrics, wherePredicates, having } = buildScope(state, options);
588
- const table = adapter.tableRef(tableKey);
635
+ const { tableKey, groupByDims, hasDate, metrics, wherePredicates, having, queryCanonicalUsed } = buildScope(state, options);
636
+ const table = adapter.fromSql(tableKey, { queryCanonical: queryCanonicalUsed });
589
637
  const schema = adapter.schema;
590
638
  const cteSelect = [];
591
639
  for (const d of groupByDims) {
@@ -632,8 +680,8 @@ function resolveToSQLOptimized(state, options) {
632
680
  }
633
681
  function resolveToSQL(state, options) {
634
682
  const { adapter } = options;
635
- const { tableKey, groupByDims, hasDate, metrics, wherePredicates, having } = buildScope(state, options);
636
- const table = adapter.tableRef(tableKey);
683
+ const { tableKey, groupByDims, hasDate, metrics, wherePredicates, having, queryCanonicalUsed } = buildScope(state, options);
684
+ const table = adapter.fromSql(tableKey, { queryCanonical: queryCanonicalUsed });
637
685
  const selectExprs = [];
638
686
  for (const d of groupByDims) {
639
687
  const expr = adapter.dimExprSql(d, tableKey);
@@ -666,8 +714,8 @@ function resolveToSQL(state, options) {
666
714
  }
667
715
  function buildTotalsSql(state, options) {
668
716
  const { adapter } = options;
669
- const { tableKey, metrics, wherePredicates } = buildScope(state, options);
670
- const table = adapter.tableRef(tableKey);
717
+ const { tableKey, metrics, wherePredicates, queryCanonicalUsed } = buildScope(state, options);
718
+ const table = adapter.fromSql(tableKey, { queryCanonical: queryCanonicalUsed });
671
719
  const selectExprs = metrics.map((m) => sql`${adapter.metricSql(m, tableKey)} as ${aliasRaw(m)}`);
672
720
  return compileCollapsed(adapter, wherePredicates.length > 0 ? sql`SELECT ${joinComma(selectExprs)} FROM ${table} WHERE ${joinAnd(wherePredicates)}` : sql`SELECT ${joinComma(selectExprs)} FROM ${table}`);
673
721
  }
@@ -677,7 +725,7 @@ function resolveComparisonSQL(current, previous, options, comparisonFilter) {
677
725
  const currentScope = buildScope(current, options);
678
726
  const previousScope = buildScope(previous, options);
679
727
  const { tableKey, groupByDims, metrics, wherePredicates: currentWhere, having } = currentScope;
680
- const table = adapter.tableRef(tableKey);
728
+ const table = adapter.fromSql(tableKey, { queryCanonical: currentScope.queryCanonicalUsed || previousScope.queryCanonicalUsed });
681
729
  const dimSelectExprs = [];
682
730
  for (const d of groupByDims) {
683
731
  const expr = adapter.dimExprSql(d, tableKey);
@@ -735,7 +783,7 @@ function buildExtrasQueries(state, options) {
735
783
  if (!dims.includes("queryCanonical")) return extras;
736
784
  const queriesKey = adapter.tableKeyForDataset("queries");
737
785
  const t = adapter.schema[queriesKey];
738
- const table = adapter.tableRef(queriesKey);
786
+ const table = adapter.fromSql(queriesKey, { queryCanonical: true });
739
787
  const whereParts = [];
740
788
  if (adapter.siteIdColRef && siteId != null) whereParts.push(sql`${adapter.siteIdColRef(queriesKey)} = ${siteId}`);
741
789
  if (adapter.searchTypeColRef && searchType != null) whereParts.push(sql`${adapter.searchTypeColRef(queriesKey)} = ${searchType}`);
@@ -743,7 +791,7 @@ function buildExtrasQueries(state, options) {
743
791
  whereParts.push(sql`${adapter.dateColRef(queriesKey)} <= ${plan.dateRange.endDate}`);
744
792
  const whereExpr = whereParts.length > 0 ? sql`WHERE ${joinAnd(whereParts)}` : sql``;
745
793
  const outerQueryCol = sql.raw("query");
746
- const canonKey = sql`COALESCE(NULLIF(${t.query_canonical}, ''), ${t.query})`;
794
+ const canonKey = adapter.dimExprSql("queryCanonical", queriesKey);
747
795
  const compiled = compileCollapsed(adapter, sql`WITH per_variant AS (SELECT ${canonKey} as joinKey, ${t.query} as query, SUM(${t.clicks}) as clicks, SUM(${t.impressions}) as impressions, SUM(${t.sum_position}) as sum_pos, ROW_NUMBER() OVER (PARTITION BY ${canonKey} ORDER BY SUM(${t.clicks}) DESC) as rn, COUNT(*) OVER (PARTITION BY ${canonKey}) as variantCount FROM ${table} ${whereExpr} GROUP BY ${canonKey}, ${t.query}) SELECT joinKey, MAX(variantCount) as variantCount, MAX(CASE WHEN rn = 1 THEN ${outerQueryCol} END) as canonicalName, GROUP_CONCAT(CASE WHEN rn <= 10 THEN ${outerQueryCol} || ':::' || clicks || ':::' || impressions || ':::' || CAST(ROUND(CAST(sum_pos AS REAL) / NULLIF(impressions, 0) + 1, 1) AS TEXT) END, '||') as variants FROM per_variant GROUP BY joinKey`);
748
796
  extras.push({
749
797
  key: "canonicalExtras",
@@ -890,9 +938,80 @@ function matchesMetricFilter(row, filter) {
890
938
  function matchesTopLevelPage(row) {
891
939
  return (normalizeUrl(dimensionValue(row, "page")).match(/\//g)?.length ?? 0) <= 1;
892
940
  }
941
+ var QuerySourceCoverageError = class extends Error {
942
+ fallback;
943
+ name = "QuerySourceCoverageError";
944
+ constructor(fallback) {
945
+ super(fallback.message);
946
+ this.fallback = fallback;
947
+ }
948
+ };
893
949
  function canonicalSourceWithinCoverage(source, windowEnd) {
894
950
  return source.coversThrough === void 0 || windowEnd <= source.coversThrough;
895
951
  }
952
+ function querySourceDecision(kind, fallbacks = []) {
953
+ const first = fallbacks[0];
954
+ return {
955
+ kind,
956
+ ...first ? {
957
+ fallback: first,
958
+ fallbacks
959
+ } : {}
960
+ };
961
+ }
962
+ function canonicalFeatureRequested(state, capabilities) {
963
+ const plan = buildLogicalPlan(state, capabilities);
964
+ return plan.groupByDimensions.includes("queryCanonical") || plan.dimensionFilters.some((f) => f.dimension === "queryCanonical");
965
+ }
966
+ function fallback(kind, message) {
967
+ return {
968
+ kind,
969
+ message
970
+ };
971
+ }
972
+ function staleQueryDim(source, requirements) {
973
+ if (requirements?.normalizerVersion !== void 0 && source.normalizerVersion !== requirements.normalizerVersion) return fallback("query-dim-stale-version", `canonical query dimension normalizer v${source.normalizerVersion} does not match required v${requirements.normalizerVersion}`);
974
+ if (requirements?.intentVersion !== void 0 && source.intentVersion !== requirements.intentVersion) return fallback("query-dim-stale-version", `canonical query dimension intent v${source.intentVersion ?? "missing"} does not match required v${requirements.intentVersion}`);
975
+ }
976
+ function canonicalQueryDim(options) {
977
+ return options.queryDim ?? options.canonicalSource?.queryDim;
978
+ }
979
+ function queryDimMiss(source, requirements) {
980
+ if (!source) return fallback("query-dim-missing", "canonical query requires queryDim; fact tables do not carry query_canonical");
981
+ if (source.keys.length === 0) return fallback("query-dim-empty", "queryDim has no parquet keys; refusing to run canonical query from an empty dimension");
982
+ return staleQueryDim(source, requirements);
983
+ }
984
+ function decideCanonicalSource(state, capabilities, options, windowEnd) {
985
+ if (!canonicalFeatureRequested(state, capabilities)) return querySourceDecision("raw-partitions");
986
+ const dimMiss = queryDimMiss(canonicalQueryDim(options), options.canonicalRequirements);
987
+ if (dimMiss) throw new QuerySourceCoverageError(dimMiss);
988
+ const source = options.canonicalSource;
989
+ const miss = (() => {
990
+ if (!source) return fallback("canonical-source-missing", "canonical rollup source missing; falling back to primary facts joined through query_dim");
991
+ if (source.keys.length === 0) return fallback("canonical-source-empty", "canonicalSource has no parquet keys; falling back to primary facts joined through query_dim");
992
+ if (!canonicalSourceWithinCoverage(source, windowEnd)) return fallback("canonical-source-stale-coverage", `canonicalSource covers through ${source.coversThrough}, but query needs ${windowEnd}`);
993
+ if (!canonicalRollupCovers(state, capabilities)) return fallback("canonical-source-not-coverable", "canonicalSource only covers queryCanonical/date reads without raw-grain filters");
994
+ })();
995
+ if (!miss) return querySourceDecision("canonical-rollup");
996
+ return querySourceDecision("raw-partitions", [miss]);
997
+ }
998
+ function primarySourceMiss(source, dateRange) {
999
+ if (!source) return fallback("primary-source-missing", "primary columnar source is required; pass primarySourceFallback: \"raw\" to use raw daily partitions explicitly");
1000
+ if (source.keys.length === 0) return fallback("primary-source-empty", "primarySource has no parquet keys; refusing to return partial or empty data implicitly");
1001
+ if (source.coversFrom !== void 0 && dateRange.startDate < source.coversFrom) return fallback("primary-source-missing-coverage", `primarySource covers from ${source.coversFrom}, but query starts ${dateRange.startDate}`);
1002
+ if (source.coversThrough !== void 0 && dateRange.endDate > source.coversThrough) return fallback("primary-source-stale-coverage", `primarySource covers through ${source.coversThrough}, but query needs ${dateRange.endDate}`);
1003
+ }
1004
+ function decidePrimarySource(options, dateRange, priorFallbacks = []) {
1005
+ const miss = primarySourceMiss(options.primarySource, dateRange);
1006
+ if (!miss) return querySourceDecision("primary-columnar", priorFallbacks);
1007
+ const fallbacks = [...priorFallbacks, miss];
1008
+ if (options.primarySourceFallback === "raw") return querySourceDecision("raw-partitions", fallbacks);
1009
+ throw new QuerySourceCoverageError(miss);
1010
+ }
1011
+ function sourceFallbacks(source) {
1012
+ if (source.fallbacks) return source.fallbacks;
1013
+ return source.fallback ? [source.fallback] : [];
1014
+ }
896
1015
  function runArgs(ctx, partitions) {
897
1016
  return {
898
1017
  ctx: {
@@ -907,26 +1026,58 @@ function runArgs(ctx, partitions) {
907
1026
  ...ctx.searchType !== void 0 ? { searchType: ctx.searchType } : {}
908
1027
  };
909
1028
  }
1029
+ function primaryRunArgs(ctx, keys) {
1030
+ return {
1031
+ ctx: {
1032
+ userId: ctx.userId,
1033
+ siteId: ctx.siteId
1034
+ },
1035
+ table: ctx.table,
1036
+ fileSets: { FILES: {
1037
+ table: ctx.table,
1038
+ keys
1039
+ } },
1040
+ ...ctx.searchType !== void 0 ? { searchType: ctx.searchType } : {}
1041
+ };
1042
+ }
1043
+ function withQueryDimFileSet(args, queryDim, enabled) {
1044
+ if (!enabled || !queryDim) return args;
1045
+ return {
1046
+ ...args,
1047
+ fileSets: {
1048
+ ...args.fileSets,
1049
+ QUERY_DIM: {
1050
+ table: "queries",
1051
+ keys: queryDim.keys
1052
+ }
1053
+ }
1054
+ };
1055
+ }
910
1056
  async function runOptimizedQuery(runSQL, ctx, state, dateRange, options = {}) {
911
1057
  const base = runArgs(ctx, enumeratePartitions(dateRange.startDate, dateRange.endDate));
912
- const probe = createParquetResolverAdapter({ canonicalFallback: options.canonicalFallback ?? false });
913
- const useCanonicalSource = options.canonicalSource !== void 0 && (options.canonicalFallback ?? false) && canonicalSourceWithinCoverage(options.canonicalSource, dateRange.endDate) && canonicalRollupCovers(state, probe.capabilities);
914
- const adapter = useCanonicalSource ? createParquetResolverAdapter({ canonicalFallback: false }) : probe;
1058
+ const probe = createParquetResolverAdapter();
1059
+ const canonicalRequested = canonicalFeatureRequested(state, probe.capabilities);
1060
+ const canonicalDecision = decideCanonicalSource(state, probe.capabilities, options, dateRange.endDate);
1061
+ const useCanonicalSource = canonicalDecision.kind === "canonical-rollup";
1062
+ const source = useCanonicalSource ? canonicalDecision : decidePrimarySource(options, dateRange, sourceFallbacks(canonicalDecision));
915
1063
  const optimized = resolveToSQLOptimized(state, {
916
- adapter,
1064
+ adapter: useCanonicalSource ? createParquetResolverAdapter({ queryCanonicalSource: "column" }) : probe,
917
1065
  siteId: void 0
918
1066
  });
919
1067
  const extras = buildExtrasQueries(state, {
920
- adapter,
1068
+ adapter: probe,
921
1069
  siteId: void 0
922
1070
  });
923
- const mainArgs = useCanonicalSource ? {
1071
+ const extraSource = extras.length > 0 ? useCanonicalSource ? decidePrimarySource(options, dateRange) : source : void 0;
1072
+ const queryDim = canonicalQueryDim(options);
1073
+ const mainArgs = withQueryDimFileSet(useCanonicalSource ? {
924
1074
  ...base,
925
1075
  fileSets: { FILES: {
926
1076
  table: ctx.table,
927
1077
  keys: options.canonicalSource.keys
928
1078
  } }
929
- } : base;
1079
+ } : source.kind === "primary-columnar" ? primaryRunArgs(ctx, options.primarySource.keys) : base, queryDim, canonicalRequested && !useCanonicalSource);
1080
+ const extraArgs = withQueryDimFileSet(extraSource?.kind === "primary-columnar" ? primaryRunArgs(ctx, options.primarySource.keys) : base, queryDim, extras.length > 0);
930
1081
  const resolveExtra = options.resolveExtra;
931
1082
  const [optRes, ...extrasRows] = await Promise.all([runSQL({
932
1083
  ...mainArgs,
@@ -940,7 +1091,7 @@ async function runOptimizedQuery(runSQL, ctx, state, dateRange, options = {}) {
940
1091
  dateRange
941
1092
  }) : null;
942
1093
  return overlaid !== null ? { rows: overlaid } : runSQL({
943
- ...base,
1094
+ ...extraArgs,
944
1095
  sql: e.sql,
945
1096
  params: e.params
946
1097
  });
@@ -963,13 +1114,25 @@ async function runOptimizedQuery(runSQL, ctx, state, dateRange, options = {}) {
963
1114
  extras: extras.map((e, i) => ({
964
1115
  key: e.key,
965
1116
  rows: extrasRows[i].rows
966
- }))
1117
+ })),
1118
+ source,
1119
+ ...extraSource ? { extraSource } : {}
967
1120
  };
968
1121
  }
969
1122
  async function runComparisonQuery(runSQL, ctx, current, previous, windows, filter, options = {}) {
970
- const probe = createParquetResolverAdapter({ canonicalFallback: options.canonicalFallback ?? false });
971
- const useCanonicalSource = options.canonicalSource !== void 0 && (options.canonicalFallback ?? false) && canonicalSourceWithinCoverage(options.canonicalSource, windows.current.endDate > windows.previous.endDate ? windows.current.endDate : windows.previous.endDate) && canonicalRollupCovers(current, probe.capabilities) && canonicalRollupCovers(previous, probe.capabilities);
972
- const adapter = useCanonicalSource ? createParquetResolverAdapter({ canonicalFallback: false }) : probe;
1123
+ const probe = createParquetResolverAdapter();
1124
+ const maxWindowEnd = windows.current.endDate > windows.previous.endDate ? windows.current.endDate : windows.previous.endDate;
1125
+ const currentSource = decideCanonicalSource(current, probe.capabilities, options, maxWindowEnd);
1126
+ const previousSource = decideCanonicalSource(previous, probe.capabilities, options, maxWindowEnd);
1127
+ const startDate = windows.current.startDate < windows.previous.startDate ? windows.current.startDate : windows.previous.startDate;
1128
+ const endDate = windows.current.endDate > windows.previous.endDate ? windows.current.endDate : windows.previous.endDate;
1129
+ const comparisonRange = {
1130
+ startDate,
1131
+ endDate
1132
+ };
1133
+ const source = currentSource.kind === "canonical-rollup" && previousSource.kind === "canonical-rollup" ? querySourceDecision("canonical-rollup") : decidePrimarySource(options, comparisonRange, [...sourceFallbacks(currentSource), ...sourceFallbacks(previousSource)]);
1134
+ const useCanonicalSource = source.kind === "canonical-rollup";
1135
+ const adapter = useCanonicalSource ? createParquetResolverAdapter({ queryCanonicalSource: "column" }) : probe;
973
1136
  const comparison = resolveComparisonSQL(current, previous, {
974
1137
  adapter,
975
1138
  siteId: void 0
@@ -978,14 +1141,14 @@ async function runComparisonQuery(runSQL, ctx, current, previous, windows, filte
978
1141
  adapter,
979
1142
  siteId: void 0
980
1143
  });
981
- const partitions = enumeratePartitions(windows.current.startDate < windows.previous.startDate ? windows.current.startDate : windows.previous.startDate, windows.current.endDate > windows.previous.endDate ? windows.current.endDate : windows.previous.endDate);
982
- const base = useCanonicalSource ? {
1144
+ const partitions = enumeratePartitions(startDate, endDate);
1145
+ const base = withQueryDimFileSet(useCanonicalSource ? {
983
1146
  ...runArgs(ctx, partitions),
984
1147
  fileSets: { FILES: {
985
1148
  table: ctx.table,
986
1149
  keys: options.canonicalSource.keys
987
1150
  } }
988
- } : runArgs(ctx, partitions);
1151
+ } : source.kind === "primary-columnar" ? primaryRunArgs(ctx, options.primarySource.keys) : runArgs(ctx, partitions), canonicalQueryDim(options), !useCanonicalSource && (canonicalFeatureRequested(current, probe.capabilities) || canonicalFeatureRequested(previous, probe.capabilities)));
989
1152
  const main = await runSQL({
990
1153
  ...base,
991
1154
  sql: comparison.sql,
@@ -1004,7 +1167,8 @@ async function runComparisonQuery(runSQL, ctx, current, previous, windows, filte
1004
1167
  return {
1005
1168
  rows: main.rows,
1006
1169
  totalCount: Number(count.rows[0]?.total ?? 0),
1007
- totals: totalsRow.rows[0] ?? {}
1170
+ totals: totalsRow.rows[0] ?? {},
1171
+ source
1008
1172
  };
1009
1173
  }
1010
1174
  function assertSchemaInSync(options) {
@@ -1017,4 +1181,4 @@ function assertSchemaInSync(options) {
1017
1181
  if (missing.length > 0 || extra.length > 0) throw new Error(`${label} drizzle schema for '${key}' drifted from SCHEMAS. Missing: [${missing.join(", ")}]. Extra: [${extra.join(", ")}].`);
1018
1182
  }
1019
1183
  }
1020
- export { DIMENSION_SURFACES, LOGICAL_DATASETS, UnresolvableDatasetError, assertDimensionsSupported, assertSchemaInSync, buildExtrasQueries, buildTotalsSql, canonicalRollupCovers, createIcebergResolverAdapter, createParquetResolverAdapter, createResolverAdapter, createRollupExtrasOverlay, createSqlFragments, dimensionColumn, dimensionValue, getDimensionFilters, getFilterDimensions, getInternalFilters, inferLogicalDataset, isDatasetResolvable, matchesDimensionFilter, matchesMetricFilter, matchesTopLevelPage, mergeExtras, metricValue, pgResolverAdapter, planCoveredByCanonicalRollup, resolveComparisonSQL, resolveToSQL, resolveToSQLOptimized, runComparisonQuery, runOptimizedQuery, supportsDimensionOnSurface };
1184
+ export { DIMENSION_SURFACES, LOGICAL_DATASETS, QuerySourceCoverageError, UnresolvableDatasetError, assertDimensionsSupported, assertSchemaInSync, buildExtrasQueries, buildTotalsSql, canonicalRollupCovers, createIcebergResolverAdapter, createParquetResolverAdapter, createR2SqlResolverAdapter, createResolverAdapter, createRollupExtrasOverlay, createSqlFragments, dimensionColumn, dimensionValue, getDimensionFilters, getFilterDimensions, getInternalFilters, inferLogicalDataset, isDatasetResolvable, matchesDimensionFilter, matchesMetricFilter, matchesTopLevelPage, mergeExtras, metricValue, pgResolverAdapter, planCoveredByCanonicalRollup, resolveComparisonSQL, resolveToSQL, resolveToSQLOptimized, runComparisonQuery, runOptimizedQuery, supportsDimensionOnSurface };