@gscdump/engine-duckdb-wasm 0.19.6 → 0.20.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/README.md CHANGED
@@ -38,6 +38,26 @@ const scope = scopeFor('keywords', { siteId, window })
38
38
  const rows = await strikingMomentum(runner, { ...scope, limit: 50 })
39
39
  ```
40
40
 
41
+ ## Browser Parquet Attachment Strategy
42
+
43
+ `attachParquetUrlTables()` uses URL registration with DuckDB-WASM's HTTP file
44
+ reader rather than downloading parquet objects into JS memory. Each exact-object
45
+ URL is preflighted with `HEAD`; if the endpoint does not support `HEAD`, the
46
+ runtime performs a one-byte `Range: bytes=0-0` probe. Attachment fails closed
47
+ unless the response proves `Content-Length` / `Content-Range` and byte-range
48
+ support.
49
+
50
+ The runtime keeps local guards independent of the server response:
51
+ `maxFiles`, `maxBytes`, `fetchConcurrency`, and `AbortSignal` are enforced
52
+ before registering files. `bootDuckDBWasm()` opens DuckDB with full HTTP reads
53
+ disabled, so a server that cannot satisfy range reads routes to server-side
54
+ fallback instead of causing broad browser object downloads.
55
+
56
+ OPFS is intentionally not used as a write-through parquet cache yet. If it is
57
+ added later, the cache key must include manifest version and object key, and
58
+ the same file/byte budgets must gate admission before any object is materialized
59
+ locally.
60
+
41
61
  ### Engine source for analyzer dispatch
42
62
 
43
63
  ```ts
package/dist/index.d.mts CHANGED
@@ -4,7 +4,7 @@ import { DrizzleSchema as Schema, countries, devices, drizzleSchema as schema, h
4
4
  import { ScopedRunnerOptions, TableScope } from "@gscdump/engine/scope";
5
5
  import { AnalyzerRegistry } from "@gscdump/engine/analyzer";
6
6
  import { ComparisonMode, ResolveWindowOptions, ResolvedWindow, WindowPreset, resolveWindow } from "@gscdump/engine/period";
7
- import { AsyncDuckDB, AsyncDuckDBConnection, DuckDBBundles } from "@duckdb/duckdb-wasm";
7
+ import { AsyncDuckDB, AsyncDuckDBConnection, DuckDBBundles, DuckDBConfig } from "@duckdb/duckdb-wasm";
8
8
  import { AnalysisParams } from "@gscdump/engine/analysis-types";
9
9
  interface DuckDBWasmClient {
10
10
  db: AsyncDuckDB;
@@ -88,6 +88,12 @@ interface BootDuckDBWasmOptions {
88
88
  * worker assets themselves (e.g. Cloudflare Workers' 25 MB per-asset cap).
89
89
  */
90
90
  bundles?: DuckDBBundles;
91
+ /**
92
+ * Extra DuckDB open config. The browser runtime always forces HTTP files
93
+ * into range-only mode (reliable HEAD probes, no full HTTP fallback) so a
94
+ * server that cannot answer bounded reads fails closed.
95
+ */
96
+ config?: DuckDBConfig;
91
97
  }
92
98
  interface BrowserParquetFile {
93
99
  bytes: Uint8Array;
@@ -113,7 +119,24 @@ interface AttachParquetUrlTablesOptions {
113
119
  tables: BrowserParquetUrlTable[];
114
120
  fetch?: typeof fetch;
115
121
  schema?: string;
122
+ /**
123
+ * Request init used only for runtime-owned HEAD / one-byte Range preflights.
124
+ * DuckDB-WASM's internal HTTP reader cannot receive custom fetch headers;
125
+ * URL reads must therefore be authorized by the URL itself.
126
+ */
116
127
  fetchInit?: RequestInit;
128
+ /**
129
+ * Caps simultaneous URL preflights. Browser source endpoints should already
130
+ * return small, coverage-planned URL sets; this is the runtime's local
131
+ * guard against accidental unbounded attachment.
132
+ */
133
+ fetchConcurrency?: number;
134
+ /** Reject before preflight when the URL set exceeds this many parquet files. */
135
+ maxFiles?: number;
136
+ /** Reject before registration when hinted or authoritative bytes exceed budget. */
137
+ maxBytes?: number;
138
+ /** Abort signal passed through to URL preflights and registration. */
139
+ signal?: AbortSignal;
117
140
  /**
118
141
  * Manifest version the caller associates with this set of URLs. Returned
119
142
  * on the resulting handle so callers can compare against a fresh manifest
@@ -123,9 +146,11 @@ interface AttachParquetUrlTablesOptions {
123
146
  version?: number | string;
124
147
  /**
125
148
  * Called once per parquet file after it's been fetched and registered with
126
- * DuckDB. Fires in non-deterministic order (Promise.all under the hood).
127
- * Used by UI progress indicators to tick a per-site counter; a no-op
128
- * default keeps the hot path free.
149
+ * DuckDB. For URL-backed HTTP files this means "preflighted and registered"
150
+ * rather than fully downloaded; DuckDB then performs range reads during the
151
+ * query. Fires in bounded-concurrency completion order, which is still not
152
+ * guaranteed to match manifest URL order. Used by UI progress indicators to
153
+ * tick a per-site counter; a no-op default keeps the hot path free.
129
154
  */
130
155
  onFileAttached?: (info: {
131
156
  table: string;
@@ -170,6 +195,9 @@ interface BrowserAnalysisRuntime {
170
195
  setAttachedTables: (tables: readonly string[]) => void;
171
196
  close: () => Promise<void>;
172
197
  }
198
+ declare class BrowserAttachBudgetExceededError extends Error {
199
+ name: string;
200
+ }
173
201
  declare function bootDuckDBWasm(options?: BootDuckDBWasmOptions): Promise<DuckDBWasmBootResult>;
174
202
  declare function attachParquetTables(options: AttachParquetTablesOptions): Promise<void>;
175
203
  declare function attachParquetUrlTables(options: AttachParquetUrlTablesOptions): Promise<AttachedTablesHandle>;
@@ -178,4 +206,4 @@ declare function createBrowserAnalysisRuntime(boot: DuckDBWasmBootResult, option
178
206
  version?: number | string;
179
207
  attachedTables?: readonly string[];
180
208
  }): BrowserAnalysisRuntime;
181
- export { type AnalyzeResult, type AttachParquetTablesOptions, type AttachParquetUrlTablesOptions, type AttachedTablesHandle, type BootDuckDBWasmOptions, type BrowserAnalysisRuntime, type BrowserParquetFile, type BrowserParquetTable, type BrowserParquetUrlTable, type ComparisonMode, type DuckDBWasmBootResult, type DuckDBWasmClient, DuckDBWasmDatabase, type DuckDBWasmDrizzleDatabase, type InsightRunner, type InsightRunnerOptions, type QueryResult, type ResolveWindowOptions, type ResolvedWindow, type Schema, type ScopedRunnerOptions, type StrikingMomentumOptions, type StrikingMomentumRow, type TableScope, type WindowPreset, attachParquetTables, attachParquetUrlTables, bootDuckDBWasm, countries, createBrowserAnalysisRuntime, createClient, createInsightRunner, devices, drizzle, hourly_pages, keywords, mergeScope, page_keywords, pages, resolveWindow, schema, scopeFor, strikingMomentum };
209
+ export { type AnalyzeResult, type AttachParquetTablesOptions, type AttachParquetUrlTablesOptions, type AttachedTablesHandle, type BootDuckDBWasmOptions, type BrowserAnalysisRuntime, BrowserAttachBudgetExceededError, type BrowserParquetFile, type BrowserParquetTable, type BrowserParquetUrlTable, type ComparisonMode, type DuckDBWasmBootResult, type DuckDBWasmClient, DuckDBWasmDatabase, type DuckDBWasmDrizzleDatabase, type InsightRunner, type InsightRunnerOptions, type QueryResult, type ResolveWindowOptions, type ResolvedWindow, type Schema, type ScopedRunnerOptions, type StrikingMomentumOptions, type StrikingMomentumRow, type TableScope, type WindowPreset, attachParquetTables, attachParquetUrlTables, bootDuckDBWasm, countries, createBrowserAnalysisRuntime, createClient, createInsightRunner, devices, drizzle, hourly_pages, keywords, mergeScope, page_keywords, pages, resolveWindow, schema, scopeFor, strikingMomentum };
package/dist/index.mjs CHANGED
@@ -167,12 +167,140 @@ async function createInsightRunner(opts) {
167
167
  };
168
168
  }
169
169
  const { scopeFor, mergeScope } = createScopedHelpers(schema);
170
+ const DEFAULT_ATTACH_FETCH_CONCURRENCY = 2;
171
+ const DEFAULT_ATTACH_MAX_FILES = 32;
172
+ const DEFAULT_ATTACH_MAX_BYTES = 16 * 1024 * 1024;
173
+ let nextAttachId = 0;
174
+ var BrowserAttachBudgetExceededError = class extends Error {
175
+ name = "BrowserAttachBudgetExceededError";
176
+ };
170
177
  function fileName(table, index, provided) {
171
178
  return provided ?? `${table}_${index}.parquet`;
172
179
  }
180
+ function attachFileName(attachId, table, index) {
181
+ return `__gscdump_attach_${attachId}_${fileName(table, index)}`;
182
+ }
173
183
  function readParquetViewSql(schema, table, files) {
174
184
  return `CREATE OR REPLACE VIEW ${schema}.${table} AS SELECT * REPLACE (CAST(date AS DATE) AS date) FROM read_parquet([${files.map((name) => `'${sqlEscape(name)}'`).join(", ")}], union_by_name = true)`;
175
185
  }
186
+ function positiveInteger(value, fallback, label) {
187
+ const raw = value ?? fallback;
188
+ if (!Number.isFinite(raw) || raw < 1) throw new Error(`${label} must be a positive integer`);
189
+ return Math.floor(raw);
190
+ }
191
+ async function runWithConcurrency(items, concurrency, fn) {
192
+ let next = 0;
193
+ let failed = false;
194
+ async function worker() {
195
+ while (!failed && next < items.length) {
196
+ const index = next++;
197
+ try {
198
+ await fn(items[index], index);
199
+ } catch (err) {
200
+ failed = true;
201
+ throw err;
202
+ }
203
+ }
204
+ }
205
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
206
+ }
207
+ function sizeHintFromUrl(url) {
208
+ try {
209
+ const base = typeof globalThis.location?.href === "string" ? globalThis.location.href : "http://localhost";
210
+ const raw = new URL(url, base).searchParams.get("s")?.split(".")[0];
211
+ if (!raw) return null;
212
+ const size = Number(raw);
213
+ return Number.isFinite(size) && size >= 0 ? size : null;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+ function mergeAbortSignals(primary, secondary) {
219
+ if (!primary) return secondary;
220
+ if (!secondary) return primary;
221
+ if (primary.aborted) return primary;
222
+ if (secondary.aborted) return secondary;
223
+ const controller = new AbortController();
224
+ const abort = (signal) => {
225
+ controller.abort(signal.reason);
226
+ };
227
+ primary.addEventListener("abort", () => abort(primary), { once: true });
228
+ secondary.addEventListener("abort", () => abort(secondary), { once: true });
229
+ return controller.signal;
230
+ }
231
+ function fetchInitFor(fetchInit, method, signal, extraHeaders) {
232
+ const { body: _body, method: _method, signal: initSignal, headers: initHeaders, ...rest } = fetchInit ?? {};
233
+ const headers = new Headers(initHeaders);
234
+ for (const [key, value] of Object.entries(extraHeaders ?? {})) headers.set(key, value);
235
+ return {
236
+ ...rest,
237
+ method,
238
+ headers,
239
+ signal: mergeAbortSignals(signal, initSignal ?? void 0)
240
+ };
241
+ }
242
+ function isAbortError(err) {
243
+ return typeof err === "object" && err !== null && err.name === "AbortError";
244
+ }
245
+ function parseContentLength(headers) {
246
+ const raw = headers.get("content-length");
247
+ if (!raw) return null;
248
+ const size = Number(raw);
249
+ return Number.isFinite(size) && size >= 0 ? size : null;
250
+ }
251
+ function parseContentRangeSize(headers) {
252
+ const raw = headers.get("content-range");
253
+ if (!raw) return null;
254
+ const match = /^bytes\s+\d+-\d+\/(\d+)$/i.exec(raw);
255
+ if (!match) return null;
256
+ const size = Number(match[1]);
257
+ return Number.isFinite(size) && size >= 0 ? size : null;
258
+ }
259
+ function supportsRangeReads(headers) {
260
+ return headers.get("accept-ranges")?.toLowerCase().split(",").map((v) => v.trim()).includes("bytes") === true;
261
+ }
262
+ async function cancelBody(response) {
263
+ await response.body?.cancel().catch(() => void 0);
264
+ }
265
+ async function preflightHttpUrl(url, fetchImpl, fetchInit, signal) {
266
+ const head = await fetchImpl(url, fetchInitFor(fetchInit, "HEAD", signal));
267
+ if (head.ok) {
268
+ const size = parseContentLength(head.headers);
269
+ if (size === null) throw new Error(`HEAD ${url} missing Content-Length`);
270
+ if (!supportsRangeReads(head.headers)) throw new Error(`HEAD ${url} missing Accept-Ranges: bytes`);
271
+ return size;
272
+ }
273
+ await cancelBody(head);
274
+ if (![
275
+ 403,
276
+ 405,
277
+ 501
278
+ ].includes(head.status)) throw new Error(`HEAD ${url} failed: ${head.status}`);
279
+ const probe = await fetchImpl(url, fetchInitFor(fetchInit, "GET", signal, { Range: "bytes=0-0" }));
280
+ try {
281
+ if (probe.status !== 206) throw new Error(`range probe ${url} failed: ${probe.status}`);
282
+ const size = parseContentRangeSize(probe.headers);
283
+ if (size === null) throw new Error(`range probe ${url} missing Content-Range size`);
284
+ return size;
285
+ } finally {
286
+ await cancelBody(probe);
287
+ }
288
+ }
289
+ function rangeOnlyConfig(config) {
290
+ return {
291
+ ...config ?? {},
292
+ filesystem: {
293
+ ...config?.filesystem ?? {},
294
+ reliableHeadRequests: true,
295
+ allowFullHTTPReads: false,
296
+ forceFullHTTPReads: false
297
+ }
298
+ };
299
+ }
300
+ async function dropAttachedResources(db, conn, schema, tables, files) {
301
+ for (const table of tables) await conn.query(`DROP VIEW IF EXISTS ${schema}.${table}`);
302
+ if (files.length > 0) await db.dropFiles([...files]);
303
+ }
176
304
  async function bootDuckDBWasm(options = {}) {
177
305
  const { getJsDelivrBundles, selectBundle, AsyncDuckDB, ConsoleLogger } = await import("@duckdb/duckdb-wasm");
178
306
  const bundle = await selectBundle(options.bundles ?? getJsDelivrBundles());
@@ -180,6 +308,7 @@ async function bootDuckDBWasm(options = {}) {
180
308
  const worker = new Worker(workerUrl);
181
309
  const db = new AsyncDuckDB(options.logger ?? new ConsoleLogger(), worker);
182
310
  await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
311
+ await db.open(rangeOnlyConfig(options.config));
183
312
  URL.revokeObjectURL(workerUrl);
184
313
  return {
185
314
  db,
@@ -200,7 +329,11 @@ async function attachParquetTables(options) {
200
329
  }
201
330
  }
202
331
  async function attachParquetUrlTables(options) {
203
- const { db, conn, tables, fetch: fetchImpl = globalThis.fetch.bind(globalThis), schema = "main", fetchInit, version, onFileAttached } = options;
332
+ const { db, conn, tables, fetch: fetchImpl = globalThis.fetch.bind(globalThis), schema = "main", fetchInit, fetchConcurrency, maxFiles, maxBytes, signal, version, onFileAttached } = options;
333
+ const concurrency = positiveInteger(fetchConcurrency, DEFAULT_ATTACH_FETCH_CONCURRENCY, "fetchConcurrency");
334
+ const fileBudget = positiveInteger(maxFiles, DEFAULT_ATTACH_MAX_FILES, "maxFiles");
335
+ const byteBudget = positiveInteger(maxBytes, DEFAULT_ATTACH_MAX_BYTES, "maxBytes");
336
+ const attachId = nextAttachId++;
204
337
  const flat = [];
205
338
  const counts = {};
206
339
  for (const [table, urls] of tables.map((t) => [t.table, t.urls])) {
@@ -212,38 +345,79 @@ async function attachParquetUrlTables(options) {
212
345
  index: i
213
346
  });
214
347
  }
348
+ if (flat.length > fileBudget) throw new BrowserAttachBudgetExceededError(`browser parquet attach requires ${flat.length} files, above maxFiles=${fileBudget}`);
349
+ const hintedBytes = flat.reduce((acc, item) => {
350
+ const hint = sizeHintFromUrl(item.url);
351
+ return hint === null ? acc : acc + hint;
352
+ }, 0);
353
+ if (hintedBytes > byteBudget) throw new BrowserAttachBudgetExceededError(`browser parquet attach requires ${hintedBytes} hinted bytes, above maxBytes=${byteBudget}`);
215
354
  const tableFailures = /* @__PURE__ */ new Map();
355
+ const budgetController = new AbortController();
356
+ const effectiveSignal = mergeAbortSignals(signal, budgetController.signal);
357
+ let plannedBytes = 0;
216
358
  const total = flat.length;
217
- await Promise.all(flat.map(async ({ table, url, index }) => {
359
+ const preflighted = [];
360
+ await runWithConcurrency(flat, concurrency, async ({ table, url, index }) => {
218
361
  if (tableFailures.has(table)) return;
219
- await fetchImpl(url, fetchInit).then(async (response) => {
220
- if (!response.ok) throw new Error(`fetch ${url} failed: ${response.status}`);
221
- const bytes = new Uint8Array(await response.arrayBuffer());
222
- await db.registerFileBuffer(fileName(table, index), bytes);
223
- onFileAttached?.({
362
+ effectiveSignal?.throwIfAborted();
363
+ await preflightHttpUrl(url, fetchImpl, fetchInit, effectiveSignal).then((bytes) => {
364
+ plannedBytes += bytes;
365
+ if (plannedBytes > byteBudget) {
366
+ const err = new BrowserAttachBudgetExceededError(`browser parquet attach planned ${plannedBytes} bytes, above maxBytes=${byteBudget}`);
367
+ budgetController.abort(err);
368
+ throw err;
369
+ }
370
+ effectiveSignal?.throwIfAborted();
371
+ preflighted.push({
224
372
  table,
373
+ url,
225
374
  index,
226
- total
375
+ name: attachFileName(attachId, table, index)
227
376
  });
228
377
  }).catch((err) => {
378
+ if (effectiveSignal?.aborted || err instanceof BrowserAttachBudgetExceededError || isAbortError(err)) throw err;
229
379
  tableFailures.set(table, err instanceof Error ? err : new Error(String(err)));
230
380
  });
231
- }));
381
+ });
382
+ const { DuckDBDataProtocol } = await import("@duckdb/duckdb-wasm");
232
383
  const attached = [];
233
- for (const table of Object.keys(counts)) {
234
- if (tableFailures.has(table)) continue;
235
- const names = [];
236
- for (let i = 0; i < counts[table]; i++) names.push(fileName(table, i));
237
- await conn.query(readParquetViewSql(schema, table, names));
238
- attached.push(table);
384
+ const registeredFiles = [];
385
+ try {
386
+ for (const file of preflighted) {
387
+ if (tableFailures.has(file.table)) continue;
388
+ effectiveSignal?.throwIfAborted();
389
+ await db.registerFileURL(file.name, file.url, DuckDBDataProtocol.HTTP, false);
390
+ registeredFiles.push(file.name);
391
+ onFileAttached?.({
392
+ table: file.table,
393
+ index: file.index,
394
+ total
395
+ });
396
+ }
397
+ for (const table of Object.keys(counts)) {
398
+ if (tableFailures.has(table)) continue;
399
+ const files = preflighted.filter((file) => file.table === table).sort((a, b) => a.index - b.index);
400
+ if (files.length !== counts[table]) continue;
401
+ effectiveSignal?.throwIfAborted();
402
+ await conn.query(readParquetViewSql(schema, table, files.map((file) => file.name)));
403
+ attached.push(table);
404
+ }
405
+ } catch (err) {
406
+ await dropAttachedResources(db, conn, schema, attached, registeredFiles).catch((cleanupErr) => {
407
+ console.warn("[gscdump/engine-duckdb-wasm] cleanup after failed attach failed", cleanupErr);
408
+ });
409
+ throw err;
239
410
  }
240
411
  if (tableFailures.size > 0) for (const [table, err] of tableFailures) console.warn(`[gscdump/engine-duckdb-wasm] dropped table "${table}" — ${err.message}`);
412
+ let detached = false;
241
413
  return {
242
414
  version,
243
415
  tables: attached,
244
416
  schema,
245
417
  async detach() {
246
- for (const table of attached) await conn.query(`DROP VIEW IF EXISTS ${schema}.${table}`);
418
+ if (detached) return;
419
+ detached = true;
420
+ await dropAttachedResources(db, conn, schema, attached, registeredFiles);
247
421
  }
248
422
  };
249
423
  }
@@ -253,12 +427,32 @@ function createBrowserAnalysisRuntime(boot, options = {}) {
253
427
  let version = options.version;
254
428
  let attachedTables = options.attachedTables;
255
429
  let chain = Promise.resolve();
430
+ function abortError(signal) {
431
+ return signal.reason ?? new DOMException("aborted", "AbortError");
432
+ }
433
+ function raceSignal(promise, signal) {
434
+ if (!signal) return promise;
435
+ if (signal.aborted) return Promise.reject(abortError(signal));
436
+ return new Promise((resolve, reject) => {
437
+ const onAbort = () => reject(abortError(signal));
438
+ signal.addEventListener("abort", onAbort, { once: true });
439
+ promise.then((value) => {
440
+ signal.removeEventListener("abort", onAbort);
441
+ resolve(value);
442
+ }, (err) => {
443
+ signal.removeEventListener("abort", onAbort);
444
+ reject(err);
445
+ });
446
+ });
447
+ }
448
+ function runExclusive(signal, work) {
449
+ const next = chain.then(work, work);
450
+ chain = next.catch(() => {});
451
+ return raceSignal(next, signal);
452
+ }
256
453
  async function cancelOnAbort(signal, work) {
257
454
  if (!signal) return work;
258
- if (signal.aborted) {
259
- conn.cancelSent().catch(() => {});
260
- throw signal.reason ?? new DOMException("aborted", "AbortError");
261
- }
455
+ if (signal.aborted) throw abortError(signal);
262
456
  const onAbort = () => {
263
457
  conn.cancelSent().catch(() => {});
264
458
  };
@@ -269,7 +463,7 @@ function createBrowserAnalysisRuntime(boot, options = {}) {
269
463
  signal.removeEventListener("abort", onAbort);
270
464
  }
271
465
  }
272
- async function runParameterized(sql, params, signal) {
466
+ async function runParameterizedDirect(sql, params, signal) {
273
467
  signal?.throwIfAborted();
274
468
  return cancelOnAbort(signal, (async () => {
275
469
  if (!params || params.length === 0) return conn.query(sql);
@@ -287,7 +481,7 @@ function createBrowserAnalysisRuntime(boot, options = {}) {
287
481
  async query(sql, params, signal) {
288
482
  const t0 = performance.now();
289
483
  return {
290
- rows: arrowToRows(await runParameterized(sql, params, signal)),
484
+ rows: arrowToRows(await runExclusive(signal, () => runParameterizedDirect(sql, params, signal))),
291
485
  queryMs: performance.now() - t0
292
486
  };
293
487
  },
@@ -297,7 +491,7 @@ function createBrowserAnalysisRuntime(boot, options = {}) {
297
491
  signal?.throwIfAborted();
298
492
  const t0 = performance.now();
299
493
  const result = await runAnalyzerFromSource(createAttachedTableSource({ query: async (sql, bindParams, innerSignal) => {
300
- return arrowToRows(await runParameterized(sql, bindParams, innerSignal ?? signal));
494
+ return arrowToRows(await runParameterizedDirect(sql, bindParams, innerSignal ?? signal));
301
495
  } }, {
302
496
  schema,
303
497
  signal,
@@ -310,9 +504,7 @@ function createBrowserAnalysisRuntime(boot, options = {}) {
310
504
  queryMs: performance.now() - t0
311
505
  };
312
506
  };
313
- const next = chain.then(run, run);
314
- chain = next.catch(() => {});
315
- return next;
507
+ return runExclusive(signal, run);
316
508
  },
317
509
  isStale(expected) {
318
510
  return expected !== version;
@@ -329,4 +521,4 @@ function createBrowserAnalysisRuntime(boot, options = {}) {
329
521
  }
330
522
  };
331
523
  }
332
- export { DuckDBWasmDatabase, attachParquetTables, attachParquetUrlTables, bootDuckDBWasm, countries, createBrowserAnalysisRuntime, createClient, createInsightRunner, devices, drizzle, hourly_pages, keywords, mergeScope, page_keywords, pages, resolveWindow, schema, scopeFor, strikingMomentum };
524
+ export { BrowserAttachBudgetExceededError, DuckDBWasmDatabase, attachParquetTables, attachParquetUrlTables, bootDuckDBWasm, countries, createBrowserAnalysisRuntime, createClient, createInsightRunner, devices, drizzle, hourly_pages, keywords, mergeScope, page_keywords, pages, resolveWindow, schema, scopeFor, strikingMomentum };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/engine-duckdb-wasm",
3
3
  "type": "module",
4
- "version": "0.19.6",
4
+ "version": "0.20.0",
5
5
  "description": "DuckDB-WASM engine adapter for @gscdump/analysis — typed browser analytics against parquet via R2.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -45,8 +45,8 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "drizzle-orm": "^0.45.2",
48
- "@gscdump/engine": "0.19.6",
49
- "gscdump": "0.19.6"
48
+ "@gscdump/engine": "0.20.0",
49
+ "gscdump": "0.20.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@duckdb/duckdb-wasm": "^1.32.0",