@gscdump/cloudflare 0.32.12 → 0.33.3

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.
@@ -8,6 +8,8 @@ interface ArchetypeSqlPlan {
8
8
  params: unknown[];
9
9
  table: ArchetypeFactTable;
10
10
  }
11
+ type PartitionPredicateMode = 'bare' | 'r2-sql-concat';
12
+ type PartitionKeyEncoding = 'int' | 'string';
11
13
  interface BuildArchetypeSqlOptions {
12
14
  /**
13
15
  * Set by the DuckDB file-list executor, which reads raw Iceberg parquet
@@ -25,6 +27,14 @@ interface BuildArchetypeSqlOptions {
25
27
  * virtual columns and needs the full predicate.
26
28
  */
27
29
  partitionPruned?: boolean;
30
+ /**
31
+ * Int-partition catalogs are the default and use bare equality. Legacy
32
+ * string-partition catalogs can request CONCAT-wrapped predicates directly
33
+ * instead of post-processing generated SQL.
34
+ */
35
+ partitionPredicateMode?: PartitionPredicateMode;
36
+ /** Preferred input for new callers. Defaults to `'int'`. */
37
+ partitionKeyEncoding?: PartitionKeyEncoding;
28
38
  }
29
39
  declare function buildArchetypeSql(query: ArchetypeQuery, opts?: BuildArchetypeSqlOptions): ArchetypeSqlPlan;
30
40
  /** Row returned by the DuckDB sibling. */
@@ -132,6 +142,14 @@ interface R2SqlClientConfig {
132
142
  fetchImpl?: typeof fetch;
133
143
  /** Per-query wall-clock deadline (ms). Default 25s — under the Worker CPU budget. */
134
144
  timeoutMs?: number;
145
+ /** Partition-key encoding of the target catalog. Defaults to `'int'`. */
146
+ partitionKeyEncoding?: PartitionKeyEncoding;
147
+ /**
148
+ * Host-owned mapping from public site id to the INT `site_id` partition
149
+ * value. Required for non-numeric public ids when `partitionKeyEncoding` is
150
+ * `'int'`.
151
+ */
152
+ partitionSiteId?: (siteId: string) => string | number;
135
153
  }
136
154
  /** A row as returned by R2 SQL — flat dimension + metric values. */
137
155
  type R2SqlRow = Record<string, string | number | null>;
@@ -1,12 +1,17 @@
1
1
  import { bindLiterals } from "@gscdump/engine";
2
2
  import { ARCHETYPE_EXECUTION_CLASS } from "@gscdump/contracts/archetypes";
3
3
  import { err, ok, unwrapResult } from "gscdump/result";
4
+ import { SEARCH_TYPE_INT } from "@gscdump/engine/iceberg";
4
5
  const TABLE_PLACEHOLDER = "{{TABLE}}";
5
6
  function dimColumn(dim) {
6
7
  if (dim === "page") return "url";
7
- if (dim === "queryCanonical") return "query_canonical";
8
+ if (dim === "queryCanonical") return "COALESCE((SELECT qd.query_canonical FROM query_dim qd WHERE qd.query = query LIMIT 1), query)";
8
9
  return dim;
9
10
  }
11
+ function dimSelect(dim) {
12
+ const col = dimColumn(dim);
13
+ return dim === "queryCanonical" ? `${col} AS queryCanonical` : col;
14
+ }
10
15
  function tableForDimensions(dims) {
11
16
  const set = new Set(dims.filter((d) => d !== "date"));
12
17
  if (set.has("page") && (set.has("query") || set.has("queryCanonical"))) return "page_queries";
@@ -84,13 +89,13 @@ function moverClause(movers) {
84
89
  function sqlStringLiteral(value) {
85
90
  return `'${value.replace(/'/g, "''")}'`;
86
91
  }
87
- function partitionWhere(q, partitionPruned = false) {
92
+ function partitionWhere(q, partitionPruned = false, mode = "bare") {
88
93
  if (partitionPruned) return {
89
94
  clause: "date BETWEEN ? AND ?",
90
95
  params: [q.range.start, q.range.end]
91
96
  };
92
97
  return {
93
- clause: "site_id = ? AND search_type = ? AND date BETWEEN ? AND ?",
98
+ clause: `${mode === "r2-sql-concat" ? "CONCAT(site_id, '')" : "site_id"} = ? AND ${mode === "r2-sql-concat" ? "CONCAT(search_type, '')" : "search_type"} = ? AND date BETWEEN ? AND ?`,
94
99
  params: [
95
100
  q.siteId,
96
101
  q.searchType,
@@ -130,8 +135,8 @@ function facetPredicate(query) {
130
135
  params
131
136
  };
132
137
  }
133
- function buildSiteDailyTimeseries(q, pruned) {
134
- const w = partitionWhere(q, pruned);
138
+ function buildSiteDailyTimeseries(q, pruned, mode) {
139
+ const w = partitionWhere(q, pruned, mode);
135
140
  const metrics = q.metrics.map(metricExpr).join(", ");
136
141
  return {
137
142
  table: "dates",
@@ -139,9 +144,9 @@ function buildSiteDailyTimeseries(q, pruned) {
139
144
  sql: `SELECT date, ${metrics} FROM ${TABLE_PLACEHOLDER} WHERE ${w.clause} GROUP BY date ORDER BY date ASC`
140
145
  };
141
146
  }
142
- function buildEntityDailyTimeseries(q, pruned) {
147
+ function buildEntityDailyTimeseries(q, pruned, mode) {
143
148
  const table = tableForDimensions([q.entity.dimension]);
144
- const w = partitionWhere(q, pruned);
149
+ const w = partitionWhere(q, pruned, mode);
145
150
  const col = dimColumn(q.entity.dimension);
146
151
  const metrics = q.metrics.map(metricExpr).join(", ");
147
152
  return {
@@ -150,21 +155,21 @@ function buildEntityDailyTimeseries(q, pruned) {
150
155
  sql: `SELECT date, ${metrics} FROM ${TABLE_PLACEHOLDER} WHERE ${w.clause} AND ${col} = ? GROUP BY date ORDER BY date ASC`
151
156
  };
152
157
  }
153
- function buildEntityDailySparkline(q, pruned) {
158
+ function buildEntityDailySparkline(q, pruned, mode) {
154
159
  const table = tableForDimensions([q.dimension]);
155
- const w = partitionWhere(q, pruned);
160
+ const w = partitionWhere(q, pruned, mode);
156
161
  const col = dimColumn(q.dimension);
157
162
  if (q.entities.length === 0) throw new Error("entity-daily-sparkline: empty entities - resolver must pre-resolve the top-N list");
158
163
  const inList = q.entities.map(sqlStringLiteral).join(", ");
159
164
  return {
160
165
  table,
161
166
  params: w.params,
162
- sql: `SELECT date, ${col}, ${metricExpr(q.metric)} FROM ${TABLE_PLACEHOLDER} WHERE ${w.clause} AND ${col} IN (${inList}) GROUP BY date, ${col} ORDER BY date ASC`
167
+ sql: `SELECT date, ${dimSelect(q.dimension)}, ${metricExpr(q.metric)} FROM ${TABLE_PLACEHOLDER} WHERE ${w.clause} AND ${col} IN (${inList}) GROUP BY date, ${col} ORDER BY date ASC`
163
168
  };
164
169
  }
165
- function buildTopNBreakdown(q, pruned) {
170
+ function buildTopNBreakdown(q, pruned, mode) {
166
171
  const table = tableForDimensions([q.dimension]);
167
- const w = partitionWhere(q, pruned);
172
+ const w = partitionWhere(q, pruned, mode);
168
173
  const order = `${q.orderBy.metric} ${q.orderBy.dir.toUpperCase()}`;
169
174
  const limit = `LIMIT ${Math.max(0, Math.floor(q.limit))}`;
170
175
  const offset = q.offset && q.offset > 0 ? ` OFFSET ${Math.floor(q.offset)}` : "";
@@ -175,7 +180,7 @@ function buildTopNBreakdown(q, pruned) {
175
180
  const wPrev = partitionWhere({
176
181
  ...q,
177
182
  range: q.compareRange
178
- }, pruned);
183
+ }, pruned, mode);
179
184
  const deviceSelects = (clause, ml) => DEVICE_SUFFIXES.map((suffix) => {
180
185
  const source = deviceSource(suffix);
181
186
  const metrics = ml.map((m) => metricExprForSource(m, source)).join(", ");
@@ -208,7 +213,7 @@ function buildTopNBreakdown(q, pruned) {
208
213
  const wPrev = partitionWhere({
209
214
  ...q,
210
215
  range: q.compareRange
211
- }, pruned);
216
+ }, pruned, mode);
212
217
  const curMetrics = metricList.map(metricExpr).join(", ");
213
218
  const prevMetrics = STD_METRICS.map((m) => metricExpr(m)).join(", ");
214
219
  const curCols = metricList.map((m) => coalesceMetric(m, "c", m)).join(", ");
@@ -229,17 +234,18 @@ function buildTopNBreakdown(q, pruned) {
229
234
  sql
230
235
  };
231
236
  }
232
- const sql = `SELECT ${col}, ${metricList.map(metricExpr).join(", ")}${variantSel}${totalCol} FROM ${TABLE_PLACEHOLDER} WHERE ${w.clause}${facet.sql} GROUP BY ${col} ORDER BY ${order} ${limit}${offset}`;
237
+ const metrics = metricList.map(metricExpr).join(", ");
238
+ const sql = `SELECT ${dimSelect(q.dimension)}, ${metrics}${variantSel}${totalCol} FROM ${TABLE_PLACEHOLDER} WHERE ${w.clause}${facet.sql} GROUP BY ${col} ORDER BY ${order} ${limit}${offset}`;
233
239
  return {
234
240
  table,
235
241
  params: [...w.params, ...facet.params],
236
242
  sql
237
243
  };
238
244
  }
239
- function buildSingleRowLookup(q, pruned) {
245
+ function buildSingleRowLookup(q, pruned, mode) {
240
246
  const dims = Object.keys(q.match);
241
247
  const table = tableForDimensions(dims);
242
- const w = partitionWhere(q, pruned);
248
+ const w = partitionWhere(q, pruned, mode);
243
249
  const params = [...w.params];
244
250
  let clause = w.clause;
245
251
  for (const dim of dims) {
@@ -251,12 +257,12 @@ function buildSingleRowLookup(q, pruned) {
251
257
  return {
252
258
  table,
253
259
  params,
254
- sql: `SELECT ${dims.length > 0 ? `${dims.map(dimColumn).join(", ")}, ${metrics}` : metrics} FROM ${TABLE_PLACEHOLDER} WHERE ${clause}${groupBy}`
260
+ sql: `SELECT ${dims.length > 0 ? `${dims.map(dimSelect).join(", ")}, ${metrics}` : metrics} FROM ${TABLE_PLACEHOLDER} WHERE ${clause}${groupBy}`
255
261
  };
256
262
  }
257
- function buildMultiSeriesStackedDaily(q, pruned) {
263
+ function buildMultiSeriesStackedDaily(q, pruned, mode) {
258
264
  const table = tableForDimensions([q.seriesDimension]);
259
- const w = partitionWhere(q, pruned);
265
+ const w = partitionWhere(q, pruned, mode);
260
266
  if (q.seriesDimension === "device") {
261
267
  const selects = DEVICE_SUFFIXES.map((suffix) => {
262
268
  const source = deviceSource(suffix);
@@ -272,11 +278,11 @@ function buildMultiSeriesStackedDaily(q, pruned) {
272
278
  return {
273
279
  table,
274
280
  params: w.params,
275
- sql: `SELECT date, ${col}, ${metricExpr(q.metric)} FROM ${TABLE_PLACEHOLDER} WHERE ${w.clause} GROUP BY date, ${col} ORDER BY date ASC`
281
+ sql: `SELECT date, ${dimSelect(q.seriesDimension)}, ${metricExpr(q.metric)} FROM ${TABLE_PLACEHOLDER} WHERE ${w.clause} GROUP BY date, ${col} ORDER BY date ASC`
276
282
  };
277
283
  }
278
- function buildTwoDimensionDetail(q, pruned) {
279
- const w = partitionWhere(q, pruned);
284
+ function buildTwoDimensionDetail(q, pruned, mode) {
285
+ const w = partitionWhere(q, pruned, mode);
280
286
  const params = [...w.params];
281
287
  let clause = w.clause;
282
288
  if (q.filter?.page) {
@@ -301,14 +307,15 @@ function buildTwoDimensionDetail(q, pruned) {
301
307
  }
302
308
  function buildArchetypeSql(query, opts = {}) {
303
309
  const pruned = opts.partitionPruned ?? false;
310
+ const mode = opts.partitionPredicateMode ?? (opts.partitionKeyEncoding === "string" ? "r2-sql-concat" : "bare");
304
311
  switch (query.archetype) {
305
- case "site-daily-timeseries": return buildSiteDailyTimeseries(query, pruned);
306
- case "entity-daily-timeseries": return buildEntityDailyTimeseries(query, pruned);
307
- case "entity-daily-sparkline": return buildEntityDailySparkline(query, pruned);
308
- case "top-n-breakdown": return buildTopNBreakdown(query, pruned);
309
- case "single-row-lookup": return buildSingleRowLookup(query, pruned);
310
- case "multi-series-stacked-daily": return buildMultiSeriesStackedDaily(query, pruned);
311
- case "two-dimension-detail": return buildTwoDimensionDetail(query, pruned);
312
+ case "site-daily-timeseries": return buildSiteDailyTimeseries(query, pruned, mode);
313
+ case "entity-daily-timeseries": return buildEntityDailyTimeseries(query, pruned, mode);
314
+ case "entity-daily-sparkline": return buildEntityDailySparkline(query, pruned, mode);
315
+ case "top-n-breakdown": return buildTopNBreakdown(query, pruned, mode);
316
+ case "single-row-lookup": return buildSingleRowLookup(query, pruned, mode);
317
+ case "multi-series-stacked-daily": return buildMultiSeriesStackedDaily(query, pruned, mode);
318
+ case "two-dimension-detail": return buildTwoDimensionDetail(query, pruned, mode);
312
319
  case "arbitrary-sql": throw new Error("buildArchetypeSql: arbitrary-sql carries caller SQL - the DuckDB executor runs it verbatim");
313
320
  case "aux-cloud-only": throw new Error("buildArchetypeSql: aux-cloud-only is not an Iceberg query");
314
321
  }
@@ -319,6 +326,9 @@ var ServerTailRoutingError = class extends Error {
319
326
  function routingErrorToException(error) {
320
327
  return error;
321
328
  }
329
+ function hasRegexFacet(query) {
330
+ return query.facets?.some((f) => f.op === "regex" || f.op === "notRegex") ?? false;
331
+ }
322
332
  function resolveServerTailEngineResult(query) {
323
333
  const cls = ARCHETYPE_EXECUTION_CLASS[query.archetype];
324
334
  if (cls === "cloud-only") return err(new ServerTailRoutingError(`archetype '${query.archetype}' is cloud-only — not a server-tail query`));
@@ -327,8 +337,7 @@ function resolveServerTailEngineResult(query) {
327
337
  if (query.archetype === "top-n-breakdown" && query.includeTotal) return ok("duckdb");
328
338
  if (query.archetype === "top-n-breakdown" && query.compareRange) return ok("duckdb");
329
339
  if (query.archetype === "top-n-breakdown" && query.dimension === "queryCanonical") return ok("duckdb");
330
- const facets = query.facets;
331
- if (facets && facets.length > 0) return ok("duckdb");
340
+ if (hasRegexFacet(query)) return ok("duckdb");
332
341
  return ok("r2-sql");
333
342
  }
334
343
  function resolveServerTailEngine(query) {
@@ -534,10 +543,28 @@ function normalizeRows(result) {
534
543
  }
535
544
  return [];
536
545
  }
546
+ function coerceIntSiteId(siteId, mapper) {
547
+ const mapped = mapper ? mapper(siteId) : siteId;
548
+ const n = Number(mapped);
549
+ if (!Number.isSafeInteger(n)) throw new R2SqlError("int R2 SQL catalog requires a numeric site_id partition value; pass partitionSiteId for public ids");
550
+ return n;
551
+ }
552
+ function withEncodedPartitions(query, encoding, siteIdMapper) {
553
+ if (encoding === "string") return query;
554
+ const scoped = query;
555
+ const searchType = SEARCH_TYPE_INT[scoped.searchType];
556
+ if (searchType === void 0) throw new R2SqlError(`unknown search_type for int R2 SQL catalog: ${String(scoped.searchType)}`);
557
+ return {
558
+ ...query,
559
+ siteId: coerceIntSiteId(scoped.siteId, siteIdMapper),
560
+ searchType
561
+ };
562
+ }
537
563
  function createR2SqlClient(config) {
538
564
  const fetchImpl = config.fetchImpl ?? globalThis.fetch;
539
565
  const apiBase = config.apiBase ?? DEFAULT_API_BASE;
540
566
  const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
567
+ const partitionKeyEncoding = config.partitionKeyEncoding ?? "int";
541
568
  const endpoint = `${apiBase}/accounts/${config.accountId}/r2-sql/query/${config.bucket}`;
542
569
  async function queryResult(sql) {
543
570
  const started = Date.now();
@@ -576,12 +603,16 @@ function createR2SqlClient(config) {
576
603
  async function query(sql) {
577
604
  return unwrapResult(await queryResult(sql), r2SqlErrorToException);
578
605
  }
579
- function runPlan(plan) {
606
+ function materializePlan(plan) {
580
607
  const tableRef = r2TableRef(config.namespace, plan.table);
581
- return query(workaroundPartitionEquality(inlineParams(plan.sql.split(TABLE_PLACEHOLDER).join(tableRef), plan.params)));
608
+ return inlineParams(plan.sql.split(TABLE_PLACEHOLDER).join(tableRef), plan.params);
609
+ }
610
+ function runPlan(plan) {
611
+ const sql = materializePlan(plan);
612
+ return query(partitionKeyEncoding === "string" ? workaroundPartitionEquality(sql) : sql);
582
613
  }
583
614
  function runArchetype(archetypeQuery) {
584
- return runPlan(buildArchetypeSql(archetypeQuery));
615
+ return query(materializePlan(buildArchetypeSql(withEncodedPartitions(archetypeQuery, partitionKeyEncoding, config.partitionSiteId), { partitionKeyEncoding })));
585
616
  }
586
617
  return {
587
618
  query,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/cloudflare",
3
3
  "type": "module",
4
- "version": "0.32.12",
4
+ "version": "0.33.3",
5
5
  "description": "Cloudflare-Workers-flavored helpers for the gscdump analytics stack: AnalyticsEnv binding contract, R2 SigV4 presigner, size-hint HMAC, DuckDB Workers shims, engine factory.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -46,10 +46,10 @@
46
46
  "dependencies": {
47
47
  "@uwdata/flechette": "^2.5.0",
48
48
  "aws4fetch": "^1.0.20",
49
- "@gscdump/engine-sqlite": "0.32.12",
50
- "@gscdump/engine": "0.32.12",
51
- "gscdump": "0.32.12",
52
- "@gscdump/contracts": "0.32.12"
49
+ "@gscdump/contracts": "0.33.3",
50
+ "gscdump": "0.33.3",
51
+ "@gscdump/engine-sqlite": "0.33.3",
52
+ "@gscdump/engine": "0.33.3"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@cloudflare/vitest-pool-workers": "^0.16.20",