@gscdump/cloudflare 1.3.2 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ import { ArchetypeSqlPlan, PartitionKeyEncoding } from "./archetype-sql.mjs";
2
+ import { ArchetypeQuery } from "@gscdump/contracts/archetypes";
3
+ import { Result } from "gscdump/result";
4
+ /** Configuration for an R2 SQL client. */
5
+ interface R2SqlClientConfig {
6
+ /** Cloudflare account id. */
7
+ accountId: string;
8
+ /** R2 bucket backing the Iceberg catalog — R2 SQL addresses the catalog by bucket. */
9
+ bucket: string;
10
+ /** Iceberg namespace the 5 fact tables live in. */
11
+ namespace: string;
12
+ /** Cloudflare API token with R2 Data Catalog read scope. */
13
+ token: string;
14
+ /**
15
+ * Override the HTTP endpoint base. Defaults to the public CF API. Tests
16
+ * point this at a local recorder.
17
+ */
18
+ apiBase?: string;
19
+ /**
20
+ * Injectable fetch. Defaults to global `fetch`. Tests pass a fake that
21
+ * returns a recorded CF envelope without a network round-trip.
22
+ */
23
+ fetchImpl?: typeof fetch;
24
+ /** Per-query wall-clock deadline (ms). Default 25s — under the Worker CPU budget. */
25
+ timeoutMs?: number;
26
+ /** Partition-key encoding of the target catalog. Defaults to `'int'`. */
27
+ partitionKeyEncoding?: PartitionKeyEncoding;
28
+ /**
29
+ * Host-owned mapping from public site id to the INT `site_id` partition
30
+ * value. Required for non-numeric public ids when `partitionKeyEncoding` is
31
+ * `'int'`.
32
+ */
33
+ partitionSiteId?: (siteId: string) => string | number;
34
+ }
35
+ /** A row as returned by R2 SQL — flat dimension + metric values. */
36
+ type R2SqlRow = Record<string, string | number | null>;
37
+ /** Result of an R2 SQL query. */
38
+ interface R2SqlResult {
39
+ rows: R2SqlRow[];
40
+ /** The exact SQL sent (params already inlined). For diagnostics. */
41
+ sql: string;
42
+ /** Wall-clock duration of the HTTP round-trip. */
43
+ queryMs: number;
44
+ }
45
+ declare class R2SqlError extends Error {
46
+ readonly status?: number | undefined;
47
+ name: string;
48
+ constructor(message: string, status?: number | undefined);
49
+ }
50
+ declare class R2SqlTimeoutError extends Error {
51
+ name: string;
52
+ constructor(timeoutMs: number);
53
+ }
54
+ /**
55
+ * The modelled, caller-actionable failure channel for an R2 SQL query. Callers
56
+ * branch on which class came back — a `R2SqlTimeoutError` is a transient retry
57
+ * candidate (the query outran the per-query deadline), while a `R2SqlError`
58
+ * (HTTP 4xx/5xx, a rejected envelope, a transport blow-up) is a hard failure.
59
+ * The error variant IS the existing throwable class, so the throwing wrapper
60
+ * preserves the exact identity/message tests assert (`rejects.toThrow(/HTTP 403/)`,
61
+ * `rejects.toBeInstanceOf(R2SqlTimeoutError)`). Defects — a programmer handing
62
+ * the client malformed params (`escapeSqlValue` / `inlineParams`) — are NOT
63
+ * modelled here; they keep throwing `R2SqlError` synchronously.
64
+ */
65
+ type R2SqlQueryError = R2SqlError | R2SqlTimeoutError;
66
+ /** A configured R2 SQL client. */
67
+ interface R2SqlClient {
68
+ /** Run a raw SQL string (table reference already resolved). */
69
+ query: (sql: string) => Promise<R2SqlResult>;
70
+ /**
71
+ * Errors-as-values core for {@link R2SqlClient.query}: returns the modelled
72
+ * timeout-vs-hard-fail `R2SqlQueryError` instead of throwing, so callers can
73
+ * branch on retry-ability without `instanceof` over a `catch`. Optional so a
74
+ * hand-rolled `R2SqlClient` (e.g. a host app's own endpoint-backed client) can
75
+ * implement only the throwing surface; {@link createR2SqlClient} always
76
+ * provides it.
77
+ */
78
+ queryResult?: (sql: string) => Promise<Result<R2SqlResult, R2SqlQueryError>>;
79
+ /** Run a dialect-neutral plan: resolve `{{TABLE}}`, inline params, send. */
80
+ runPlan: (plan: ArchetypeSqlPlan) => Promise<R2SqlResult>;
81
+ /** Translate + run an archetype query end to end. */
82
+ runArchetype: (query: ArchetypeQuery) => Promise<R2SqlResult>;
83
+ }
84
+ /**
85
+ * Create an R2 SQL client. The endpoint requires a real CF token in
86
+ * production; tests inject `fetchImpl` returning a recorded envelope.
87
+ */
88
+ declare function createR2SqlClient(config: R2SqlClientConfig): R2SqlClient;
89
+ export { R2SqlClient, R2SqlClientConfig, R2SqlError, R2SqlQueryError, R2SqlResult, R2SqlRow, R2SqlTimeoutError, createR2SqlClient };
@@ -0,0 +1,159 @@
1
+ import { TABLE_PLACEHOLDER, buildArchetypeSql } from "./archetype-sql.mjs";
2
+ import { err, ok, unwrapResult } from "gscdump/result";
3
+ import { SEARCH_TYPE_INT } from "@gscdump/engine/iceberg";
4
+ function r2TableRef(namespace, table) {
5
+ return `${namespace}.${table}`;
6
+ }
7
+ var R2SqlError = class extends Error {
8
+ status;
9
+ name = "R2SqlError";
10
+ constructor(message, status) {
11
+ super(message);
12
+ this.status = status;
13
+ }
14
+ };
15
+ var R2SqlTimeoutError = class extends Error {
16
+ name = "R2SqlTimeoutError";
17
+ constructor(timeoutMs) {
18
+ super(`R2 SQL query exceeded ${timeoutMs}ms deadline`);
19
+ }
20
+ };
21
+ function r2SqlErrorToException(error) {
22
+ return error;
23
+ }
24
+ const DEFAULT_API_BASE = "https://api.sql.cloudflarestorage.com/api/v1";
25
+ const DEFAULT_TIMEOUT_MS = 25e3;
26
+ const PARTITION_PREDICATE_RE = /\b(site_id|search_type)(\s*=)/g;
27
+ function workaroundPartitionEquality(sql) {
28
+ return sql.replace(PARTITION_PREDICATE_RE, (_m, col, eq) => `CONCAT(${col}, '')${eq}`);
29
+ }
30
+ function escapeSqlValue(value) {
31
+ if (value === null || value === void 0) return "NULL";
32
+ if (typeof value === "number") {
33
+ if (!Number.isFinite(value)) throw new R2SqlError(`cannot embed non-finite number in SQL: ${value}`);
34
+ return String(value);
35
+ }
36
+ if (typeof value === "bigint") return value.toString();
37
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
38
+ return `'${String(value).replace(/'/g, "''")}'`;
39
+ }
40
+ function inlineParams(sql, params) {
41
+ let out = "";
42
+ let paramIndex = 0;
43
+ let inString = false;
44
+ for (let i = 0; i < sql.length; i++) {
45
+ const ch = sql[i];
46
+ if (ch === "'") {
47
+ if (inString && sql[i + 1] === "'") {
48
+ out += "''";
49
+ i++;
50
+ continue;
51
+ }
52
+ inString = !inString;
53
+ out += ch;
54
+ continue;
55
+ }
56
+ if (ch === "?" && !inString) {
57
+ if (paramIndex >= params.length) throw new R2SqlError(`SQL has more ? placeholders than params (${params.length})`);
58
+ out += escapeSqlValue(params[paramIndex++]);
59
+ continue;
60
+ }
61
+ out += ch;
62
+ }
63
+ if (paramIndex !== params.length) throw new R2SqlError(`SQL has ${paramIndex} ? placeholders but ${params.length} params supplied`);
64
+ return out;
65
+ }
66
+ function normalizeRows(result) {
67
+ if (!result) return [];
68
+ if (Array.isArray(result.rows)) return result.rows;
69
+ if (Array.isArray(result.columns) && Array.isArray(result.data)) {
70
+ const cols = result.columns;
71
+ return result.data.map((tuple) => {
72
+ const row = {};
73
+ cols.forEach((col, idx) => {
74
+ row[col] = tuple[idx] ?? null;
75
+ });
76
+ return row;
77
+ });
78
+ }
79
+ return [];
80
+ }
81
+ function coerceIntSiteId(siteId, mapper) {
82
+ const mapped = mapper ? mapper(siteId) : siteId;
83
+ const n = Number(mapped);
84
+ if (!Number.isSafeInteger(n)) throw new R2SqlError("int R2 SQL catalog requires a numeric site_id partition value; pass partitionSiteId for public ids");
85
+ return n;
86
+ }
87
+ function withEncodedPartitions(query, encoding, siteIdMapper) {
88
+ if (encoding === "string") return query;
89
+ const scoped = query;
90
+ const searchType = SEARCH_TYPE_INT[scoped.searchType];
91
+ if (searchType === void 0) throw new R2SqlError(`unknown search_type for int R2 SQL catalog: ${String(scoped.searchType)}`);
92
+ return {
93
+ ...query,
94
+ siteId: coerceIntSiteId(scoped.siteId, siteIdMapper),
95
+ searchType
96
+ };
97
+ }
98
+ function createR2SqlClient(config) {
99
+ const fetchImpl = config.fetchImpl ?? globalThis.fetch;
100
+ const apiBase = config.apiBase ?? DEFAULT_API_BASE;
101
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
102
+ const partitionKeyEncoding = config.partitionKeyEncoding ?? "int";
103
+ const endpoint = `${apiBase}/accounts/${config.accountId}/r2-sql/query/${config.bucket}`;
104
+ async function queryResult(sql) {
105
+ const started = Date.now();
106
+ const controller = new AbortController();
107
+ const timer = setTimeout(() => controller.abort(new R2SqlTimeoutError(timeoutMs)), timeoutMs);
108
+ let response;
109
+ try {
110
+ response = await fetchImpl(endpoint, {
111
+ method: "POST",
112
+ headers: {
113
+ "authorization": `Bearer ${config.token}`,
114
+ "content-type": "application/json",
115
+ "user-agent": "gscdump-cloudflare-r2sql/1.0"
116
+ },
117
+ body: JSON.stringify({ query: sql }),
118
+ signal: controller.signal
119
+ });
120
+ } catch (error) {
121
+ if (error instanceof R2SqlTimeoutError || error?.name === "AbortError") return err(new R2SqlTimeoutError(timeoutMs));
122
+ return err(new R2SqlError(`R2 SQL request failed: ${error.message}`));
123
+ } finally {
124
+ clearTimeout(timer);
125
+ }
126
+ if (!response.ok) {
127
+ const text = await response.text().catch(() => "");
128
+ return err(new R2SqlError(`R2 SQL HTTP ${response.status}: ${text}`, response.status));
129
+ }
130
+ const envelope = await response.json();
131
+ if (!envelope.success) return err(new R2SqlError(`R2 SQL query rejected: ${envelope.errors?.map((e) => e.message).join("; ") ?? "unknown R2 SQL error"}`));
132
+ return ok({
133
+ rows: normalizeRows(envelope.result),
134
+ sql,
135
+ queryMs: Date.now() - started
136
+ });
137
+ }
138
+ async function query(sql) {
139
+ return unwrapResult(await queryResult(sql), r2SqlErrorToException);
140
+ }
141
+ function materializePlan(plan) {
142
+ const tableRef = r2TableRef(config.namespace, plan.table);
143
+ return inlineParams(plan.sql.split(TABLE_PLACEHOLDER).join(tableRef), plan.params);
144
+ }
145
+ function runPlan(plan) {
146
+ const sql = materializePlan(plan);
147
+ return query(partitionKeyEncoding === "string" ? workaroundPartitionEquality(sql) : sql);
148
+ }
149
+ function runArchetype(archetypeQuery) {
150
+ return query(materializePlan(buildArchetypeSql(withEncodedPartitions(archetypeQuery, partitionKeyEncoding, config.partitionSiteId), { partitionKeyEncoding })));
151
+ }
152
+ return {
153
+ query,
154
+ queryResult,
155
+ runPlan,
156
+ runArchetype
157
+ };
158
+ }
159
+ export { R2SqlError, R2SqlTimeoutError, createR2SqlClient, escapeSqlValue, inlineParams };
@@ -0,0 +1,19 @@
1
+ import { AnalyticsEnv } from "./env.mjs";
2
+ import { ParquetCodec, QueryExecutor, Row } from "@gscdump/engine";
3
+ declare function createDucklingsCodec(_env: AnalyticsEnv): ParquetCodec;
4
+ interface DucklingsRowCache {
5
+ clear: () => void;
6
+ get: (key: string) => Row[] | undefined;
7
+ put: (key: string, rows: Row[]) => void;
8
+ }
9
+ declare function createDucklingsRowCache(maxBytes?: number): DucklingsRowCache;
10
+ interface DucklingsExecutorOptions {
11
+ ipcChunkBytes?: number;
12
+ ipcDirectCallBytes?: number;
13
+ ipcTotalBytes?: number;
14
+ /** Per-RPC wall/queue budget. Interactive default is 22 seconds. */
15
+ rpcTimeoutMs?: number;
16
+ rowCache?: DucklingsRowCache;
17
+ }
18
+ declare function createDucklingsExecutor(env: AnalyticsEnv, opts?: DucklingsExecutorOptions): QueryExecutor;
19
+ export { DucklingsExecutorOptions, DucklingsRowCache, createDucklingsCodec, createDucklingsExecutor, createDucklingsRowCache };
@@ -0,0 +1,360 @@
1
+ import { rowsToArrowIPC } from "./arrow.mjs";
2
+ import { coerceRow } from "@gscdump/engine";
3
+ import { createHyparquetCodec, decodeParquetToRows } from "@gscdump/engine/hyparquet";
4
+ import { SCHEMAS } from "@gscdump/engine/schema";
5
+ import { bindLiterals } from "@gscdump/engine/sql";
6
+ function resolveSvc(env) {
7
+ const svc = env.DUCKDB_SVC;
8
+ if (!svc) throw new Error("DUCKDB_SVC service binding is not configured");
9
+ return svc;
10
+ }
11
+ const DUCKDB_RPC_TIMEOUT_MS = 22e3;
12
+ const WORKER_R2_MAX_FILES = 96;
13
+ const WORKER_R2_MAX_BYTES = 64 * 1024 * 1024;
14
+ const WORKER_R2_DECODE_CONCURRENCY = 2;
15
+ const WORKER_R2_HEAD_CONCURRENCY = 4;
16
+ const IPC_CHUNK_BUDGET = 8 * 1024 * 1024;
17
+ const IPC_DIRECT_CALL_BUDGET = 30 * 1024 * 1024;
18
+ const IPC_STAGED_TOTAL_BUDGET = 64 * 1024 * 1024;
19
+ var DuckDBServiceTimeoutError = class extends Error {
20
+ name = "DuckDBServiceTimeoutError";
21
+ constructor(timeoutMs) {
22
+ super(`DUCKDB_SVC.runSQL exceeded ${timeoutMs}ms deadline`);
23
+ }
24
+ };
25
+ function withDuckDBDeadline(op, timeoutMs, signal) {
26
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
27
+ return new Promise((resolve, reject) => {
28
+ let settled = false;
29
+ let timer;
30
+ let onAbort;
31
+ const cleanup = () => {
32
+ if (timer !== void 0) clearTimeout(timer);
33
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
34
+ };
35
+ const settle = (complete, value) => {
36
+ if (settled) return;
37
+ settled = true;
38
+ cleanup();
39
+ complete(value);
40
+ };
41
+ onAbort = () => settle(reject, signal.reason ?? new DOMException("Aborted", "AbortError"));
42
+ timer = setTimeout(() => settle(reject, new DuckDBServiceTimeoutError(timeoutMs)), timeoutMs);
43
+ signal?.addEventListener("abort", onAbort, { once: true });
44
+ op.then((value) => settle(resolve, value), (error) => settle(reject, error));
45
+ });
46
+ }
47
+ function runSQLWithDeadline(svc, args, timeoutMs, signal) {
48
+ const deadlineAt = Date.now() + timeoutMs;
49
+ return withDuckDBDeadline(svc.runSQL({
50
+ ...args,
51
+ deadlineAt
52
+ }), timeoutMs, signal);
53
+ }
54
+ function createDucklingsCodec(_env) {
55
+ return createHyparquetCodec();
56
+ }
57
+ const READ_PARQUET_PLACEHOLDER = /read_parquet\(\{\{(\w+)\}\}(?:\s*,[^)]*)?\)/g;
58
+ function tmpTableName(placeholder) {
59
+ const uuid = crypto.randomUUID().replace(/-/g, "_");
60
+ return `tmp_${placeholder.toLowerCase()}_${uuid}`;
61
+ }
62
+ async function mapLimit(items, concurrency, fn) {
63
+ const limit = Math.max(1, Math.floor(concurrency));
64
+ const out = Array.from({ length: items.length });
65
+ let next = 0;
66
+ let failed = false;
67
+ let failure;
68
+ async function worker() {
69
+ while (!failed && next < items.length) {
70
+ const index = next++;
71
+ try {
72
+ out[index] = await fn(items[index], index);
73
+ } catch (error) {
74
+ failed = true;
75
+ failure = error;
76
+ }
77
+ }
78
+ }
79
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
80
+ if (failed) throw failure;
81
+ return out;
82
+ }
83
+ function assertWorkerReadBudget(opts) {
84
+ const maxFiles = opts.maxFiles ?? WORKER_R2_MAX_FILES;
85
+ const maxBytes = opts.maxBytes ?? WORKER_R2_MAX_BYTES;
86
+ const entries = Object.entries(opts.fileKeys);
87
+ const totalFiles = entries.reduce((acc, [, keys]) => acc + keys.length, 0);
88
+ if (totalFiles > maxFiles) throw new Error(`createDucklingsExecutor: planned read spans ${totalFiles} files, exceeding the ${maxFiles} file Worker budget. Narrow the date range or route through a background/windowed query.`);
89
+ if (!opts.sizes) return;
90
+ let totalBytes = 0;
91
+ for (const [, keys] of entries) for (const key of keys) {
92
+ const bytes = opts.sizes[key];
93
+ if (bytes !== void 0) totalBytes += Math.max(0, bytes);
94
+ }
95
+ if (totalBytes > maxBytes) throw new Error(`createDucklingsExecutor: planned read spans ${totalBytes} bytes, exceeding the ${maxBytes} byte Worker budget. Narrow the date range or route through a background/windowed query.`);
96
+ }
97
+ const ROW_CACHE_MAX_BYTES = 16 * 1024 * 1024;
98
+ function estimateRowsBytes(rows) {
99
+ if (rows.length === 0) return 0;
100
+ const cols = Object.keys(rows[0]).length;
101
+ return rows.length * cols * 64;
102
+ }
103
+ function addInferredValue(inference, value) {
104
+ if (value === null || value === void 0) return;
105
+ inference.hasValue = true;
106
+ if (typeof value === "string") {
107
+ inference.hasString = true;
108
+ return;
109
+ }
110
+ if (typeof value === "bigint") {
111
+ inference.hasBigInt = true;
112
+ return;
113
+ }
114
+ if (typeof value === "number") {
115
+ if (!Number.isInteger(value)) inference.hasFloat = true;
116
+ if (value > 2147483647 || value < -2147483648) inference.hasBigInt = true;
117
+ }
118
+ }
119
+ function inferredColumnType(inference) {
120
+ if (!inference.hasValue || inference.hasString) return "VARCHAR";
121
+ if (inference.hasFloat) return "DOUBLE";
122
+ return inference.hasBigInt ? "BIGINT" : "INTEGER";
123
+ }
124
+ function chunkSchemaColumns(rows, schemaColumns) {
125
+ const columns = schemaColumns ? [...schemaColumns] : [];
126
+ const seen = new Set(columns.map((c) => c.name));
127
+ const extraColumns = /* @__PURE__ */ new Map();
128
+ for (const row of rows) for (const key in row) {
129
+ if (seen.has(key)) continue;
130
+ let inference = extraColumns.get(key);
131
+ if (!inference) {
132
+ inference = {
133
+ hasValue: false,
134
+ hasString: false,
135
+ hasFloat: false,
136
+ hasBigInt: false
137
+ };
138
+ extraColumns.set(key, inference);
139
+ }
140
+ addInferredValue(inference, row[key]);
141
+ }
142
+ if (extraColumns.size === 0) return schemaColumns ? columns : void 0;
143
+ for (const [name, inference] of extraColumns) columns.push({
144
+ name,
145
+ type: inferredColumnType(inference),
146
+ nullable: true
147
+ });
148
+ return columns;
149
+ }
150
+ function rowsToArrowIPCChunks(rows, schemaColumns, opts = {}) {
151
+ const maxChunkBytes = Math.max(1, Math.floor(opts.maxChunkBytes ?? IPC_CHUNK_BUDGET));
152
+ const placeholder = opts.placeholder ? `{{${opts.placeholder}}}` : "placeholder";
153
+ const chunkColumns = chunkSchemaColumns(rows, schemaColumns);
154
+ if (rows.length === 0) {
155
+ const ipc = rowsToArrowIPC([], chunkColumns);
156
+ if (ipc.byteLength > maxChunkBytes) throw new Error(`createDucklingsExecutor: empty ${placeholder} Arrow IPC schema encoded to ${ipc.byteLength} bytes, exceeding the ${maxChunkBytes}-byte service-binding chunk budget.`);
157
+ return [{
158
+ ipc,
159
+ rows: 0
160
+ }];
161
+ }
162
+ const estimatedPerRow = Math.max(1, Math.ceil(estimateRowsBytes(rows) / rows.length));
163
+ let targetRows = Math.max(1, Math.min(rows.length, Math.floor(maxChunkBytes * .75 / estimatedPerRow)));
164
+ const chunks = [];
165
+ let index = 0;
166
+ while (index < rows.length) {
167
+ let take = Math.min(targetRows, rows.length - index);
168
+ while (true) {
169
+ const slice = rows.slice(index, index + take);
170
+ const ipc = rowsToArrowIPC(slice, chunkColumns);
171
+ if (ipc.byteLength <= maxChunkBytes) {
172
+ chunks.push({
173
+ ipc,
174
+ rows: slice.length
175
+ });
176
+ index += take;
177
+ if (ipc.byteLength < maxChunkBytes * .4 && take === targetRows) targetRows = Math.min(rows.length - index || targetRows, targetRows * 2);
178
+ break;
179
+ }
180
+ if (take === 1) throw new Error(`createDucklingsExecutor: one ${placeholder} row encoded to ${ipc.byteLength} bytes of Arrow IPC, exceeding the ${maxChunkBytes}-byte service-binding chunk budget. Narrow the query or route through a background/windowed query.`);
181
+ take = Math.max(1, Math.floor(take / 2));
182
+ }
183
+ }
184
+ return chunks;
185
+ }
186
+ function createDucklingsRowCache(maxBytes = ROW_CACHE_MAX_BYTES) {
187
+ let totalBytes = 0;
188
+ const entries = /* @__PURE__ */ new Map();
189
+ return {
190
+ clear() {
191
+ entries.clear();
192
+ totalBytes = 0;
193
+ },
194
+ get(key) {
195
+ const hit = entries.get(key);
196
+ if (!hit) return void 0;
197
+ entries.delete(key);
198
+ entries.set(key, hit);
199
+ return hit.rows;
200
+ },
201
+ put(key, rows) {
202
+ const bytes = estimateRowsBytes(rows);
203
+ if (bytes > maxBytes) return;
204
+ const existing = entries.get(key);
205
+ if (existing) {
206
+ entries.delete(key);
207
+ totalBytes -= existing.bytes;
208
+ }
209
+ while (totalBytes + bytes > maxBytes) {
210
+ const oldest = entries.keys().next().value;
211
+ if (oldest === void 0) break;
212
+ const evicted = entries.get(oldest);
213
+ entries.delete(oldest);
214
+ totalBytes -= evicted.bytes;
215
+ }
216
+ entries.set(key, {
217
+ rows,
218
+ bytes
219
+ });
220
+ totalBytes += bytes;
221
+ }
222
+ };
223
+ }
224
+ function createDucklingsExecutor(env, opts = {}) {
225
+ const rowCache = opts.rowCache ?? createDucklingsRowCache();
226
+ const rpcTimeoutMs = opts.rpcTimeoutMs ?? DUCKDB_RPC_TIMEOUT_MS;
227
+ if (!Number.isFinite(rpcTimeoutMs) || rpcTimeoutMs <= 0) throw new TypeError("createDucklingsExecutor: rpcTimeoutMs must be a positive finite number");
228
+ return { async execute({ sql, params, fileKeys, placeholderTables, pushdownFilters, dataSource, signal, table }) {
229
+ signal?.throwIfAborted();
230
+ const svc = resolveSvc(env);
231
+ assertWorkerReadBudget({ fileKeys });
232
+ const cachedUnfiltered = /* @__PURE__ */ new Map();
233
+ const scheduledUnfiltered = /* @__PURE__ */ new Set();
234
+ const plannedReadKeys = [];
235
+ for (const [placeholder, keys] of Object.entries(fileKeys)) {
236
+ const filter = pushdownFilters?.[placeholder];
237
+ for (const key of keys) {
238
+ if (filter) {
239
+ plannedReadKeys.push(key);
240
+ continue;
241
+ }
242
+ const cached = cachedUnfiltered.get(key) ?? rowCache.get(key);
243
+ if (cached !== void 0) {
244
+ cachedUnfiltered.set(key, cached);
245
+ continue;
246
+ }
247
+ if (!scheduledUnfiltered.has(key)) {
248
+ scheduledUnfiltered.add(key);
249
+ plannedReadKeys.push(key);
250
+ }
251
+ }
252
+ }
253
+ if (dataSource.head && plannedReadKeys.length > 0) {
254
+ const uniqueKeys = [...new Set(plannedReadKeys)];
255
+ const sizes = {};
256
+ await mapLimit(uniqueKeys, WORKER_R2_HEAD_CONCURRENCY, async (key) => {
257
+ signal?.throwIfAborted();
258
+ sizes[key] = (await dataSource.head(key))?.bytes;
259
+ });
260
+ assertWorkerReadBudget({
261
+ fileKeys: { READS: plannedReadKeys },
262
+ sizes
263
+ });
264
+ }
265
+ const tempNames = {};
266
+ const tableChunks = {};
267
+ let totalIpcBytes = 0;
268
+ const maxChunkBytes = opts.ipcChunkBytes ?? IPC_CHUNK_BUDGET;
269
+ const maxDirectCallBytes = opts.ipcDirectCallBytes ?? IPC_DIRECT_CALL_BUDGET;
270
+ const maxTotalBytes = opts.ipcTotalBytes ?? IPC_STAGED_TOTAL_BUDGET;
271
+ const unfilteredLoads = /* @__PURE__ */ new Map();
272
+ for (const [key, rows] of cachedUnfiltered) unfilteredLoads.set(key, Promise.resolve(rows));
273
+ const loadUnfiltered = (key) => {
274
+ let loading = unfilteredLoads.get(key);
275
+ if (!loading) {
276
+ loading = (async () => {
277
+ signal?.throwIfAborted();
278
+ const bytes = await dataSource.read(key, void 0, signal);
279
+ signal?.throwIfAborted();
280
+ const rows = await decodeParquetToRows(bytes);
281
+ rowCache.put(key, rows);
282
+ return rows;
283
+ })();
284
+ unfilteredLoads.set(key, loading);
285
+ }
286
+ return loading;
287
+ };
288
+ for (const [placeholder, keys] of Object.entries(fileKeys)) {
289
+ signal?.throwIfAborted();
290
+ const filter = pushdownFilters?.[placeholder];
291
+ const perFile = await mapLimit(keys, WORKER_R2_DECODE_CONCURRENCY, async (key) => {
292
+ if (!filter) return loadUnfiltered(key);
293
+ signal?.throwIfAborted();
294
+ const bytes = await dataSource.read(key, void 0, signal);
295
+ signal?.throwIfAborted();
296
+ return await decodeParquetToRows(bytes, filter ? { filter } : {});
297
+ });
298
+ const merged = [];
299
+ for (const rows of perFile) merged.push(...rows);
300
+ const chunks = rowsToArrowIPCChunks(merged, SCHEMAS[placeholderTables?.[placeholder] ?? table]?.columns, {
301
+ maxChunkBytes,
302
+ placeholder
303
+ });
304
+ signal?.throwIfAborted();
305
+ totalIpcBytes += chunks.reduce((acc, chunk) => acc + chunk.ipc.byteLength, 0);
306
+ if (totalIpcBytes > maxTotalBytes) throw new Error(`createDucklingsExecutor: query encoded to ${totalIpcBytes} bytes of Arrow IPC across ${Object.keys(tableChunks).length + 1} placeholders, exceeding the ${maxTotalBytes}-byte service-binding transport budget. Window the query (chunk partitions / narrow the range).`);
307
+ const tmp = tmpTableName(placeholder);
308
+ tempNames[placeholder] = tmp;
309
+ tableChunks[tmp] = chunks;
310
+ }
311
+ signal?.throwIfAborted();
312
+ const finalSql = bindLiterals(sql.replace(READ_PARQUET_PLACEHOLDER, (_, placeholder) => {
313
+ const tmp = tempNames[placeholder];
314
+ if (!tmp) throw new Error(`createDucklingsExecutor: SQL references {{${placeholder}}} but no fileKeys entry provided`);
315
+ return tmp;
316
+ }), params);
317
+ const canInlineTables = totalIpcBytes <= maxDirectCallBytes && Object.values(tableChunks).every((chunks) => chunks.length === 1);
318
+ let result;
319
+ if (canInlineTables) {
320
+ const tables = {};
321
+ for (const [name, chunks] of Object.entries(tableChunks)) tables[name] = { ipc: chunks[0].ipc };
322
+ result = await runSQLWithDeadline(svc, {
323
+ sql: finalSql,
324
+ tables
325
+ }, rpcTimeoutMs, signal);
326
+ } else {
327
+ if (!svc.stageArrowTable || !svc.dropTables) throw new Error("createDucklingsExecutor: DUCKDB_SVC does not support chunked Arrow IPC staging. Deploy the gscdump-duckdb worker with stageArrowTable/dropTables support.");
328
+ const staged = /* @__PURE__ */ new Set();
329
+ let primaryError;
330
+ let cleanupError;
331
+ try {
332
+ for (const [name, chunks] of Object.entries(tableChunks)) for (const chunk of chunks) {
333
+ signal?.throwIfAborted();
334
+ staged.add(name);
335
+ await withDuckDBDeadline(svc.stageArrowTable({
336
+ table: name,
337
+ ipc: chunk.ipc
338
+ }), rpcTimeoutMs, signal);
339
+ }
340
+ result = await runSQLWithDeadline(svc, { sql: finalSql }, rpcTimeoutMs, signal);
341
+ } catch (error) {
342
+ primaryError = error;
343
+ } finally {
344
+ if (staged.size > 0) try {
345
+ await withDuckDBDeadline(svc.dropTables({ tables: [...staged] }), rpcTimeoutMs);
346
+ } catch (error) {
347
+ cleanupError = error;
348
+ }
349
+ }
350
+ if (primaryError) throw primaryError;
351
+ if (cleanupError) throw cleanupError;
352
+ result = result;
353
+ }
354
+ return {
355
+ rows: result.rows.map(coerceRow),
356
+ sql: result.sql
357
+ };
358
+ } };
359
+ }
360
+ export { DuckDBServiceTimeoutError, assertWorkerReadBudget, createDucklingsCodec, createDucklingsExecutor, createDucklingsRowCache, mapLimit, rowsToArrowIPCChunks, withDuckDBDeadline };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/cloudflare",
3
3
  "type": "module",
4
- "version": "1.3.2",
4
+ "version": "1.4.1",
5
5
  "description": "Cloudflare-Workers-flavored helpers for the gscdump analytics stack: AnalyticsEnv binding contract, R2 SigV4 presigner, DuckDB Workers shims, engine factory.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -42,16 +42,16 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@uwdata/flechette": "^2.5.0",
45
- "@gscdump/engine": "^1.3.2",
46
- "@gscdump/contracts": "^1.3.2",
47
- "@gscdump/engine-sqlite": "^1.3.2",
48
- "gscdump": "^1.3.2"
45
+ "@gscdump/contracts": "^1.4.1",
46
+ "@gscdump/engine": "^1.4.1",
47
+ "@gscdump/engine-sqlite": "^1.4.1",
48
+ "gscdump": "^1.4.1"
49
49
  },
50
50
  "devDependencies": {
51
- "@cloudflare/vitest-pool-workers": "^0.18.6",
52
- "@cloudflare/workers-types": "^5.20260718.1",
53
- "typescript": "^6.0.3",
54
- "wrangler": "^4.112.0"
51
+ "@cloudflare/vitest-pool-workers": "^0.18.7",
52
+ "@cloudflare/workers-types": "^5.20260722.1",
53
+ "typescript": "^7.0.2",
54
+ "wrangler": "^4.113.0"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "obuild",