@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 +20 -0
- package/dist/index.d.mts +33 -5
- package/dist/index.mjs +219 -27
- package/package.json +3 -3
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.
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
-
|
|
359
|
+
const preflighted = [];
|
|
360
|
+
await runWithConcurrency(flat, concurrency, async ({ table, url, index }) => {
|
|
218
361
|
if (tableFailures.has(table)) return;
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
49
|
-
"gscdump": "0.
|
|
48
|
+
"@gscdump/engine": "0.20.0",
|
|
49
|
+
"gscdump": "0.20.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@duckdb/duckdb-wasm": "^1.32.0",
|