@gscdump/cloudflare 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.
- package/dist/server-tail/index.d.mts +18 -0
- package/dist/server-tail/index.mjs +66 -35
- package/package.json +5 -5
|
@@ -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, ${
|
|
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
|
|
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(
|
|
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, ${
|
|
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
|
-
|
|
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
|
|
606
|
+
function materializePlan(plan) {
|
|
580
607
|
const tableRef = r2TableRef(config.namespace, plan.table);
|
|
581
|
-
return
|
|
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
|
|
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.
|
|
4
|
+
"version": "0.33.0",
|
|
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/
|
|
50
|
-
"@gscdump/engine": "0.
|
|
51
|
-
"gscdump": "0.
|
|
52
|
-
"
|
|
49
|
+
"@gscdump/contracts": "0.33.0",
|
|
50
|
+
"@gscdump/engine": "0.33.0",
|
|
51
|
+
"@gscdump/engine-sqlite": "0.33.0",
|
|
52
|
+
"gscdump": "0.33.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@cloudflare/vitest-pool-workers": "^0.16.20",
|