@gscdump/engine-duckdb-wasm 0.21.3 → 0.22.4
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/index.d.mts +26 -4
- package/dist/index.mjs +68 -37
- package/package.json +5 -5
package/dist/index.d.mts
CHANGED
|
@@ -58,7 +58,7 @@ declare function createInsightRunner(opts: InsightRunnerOptions): Promise<Insigh
|
|
|
58
58
|
* so consumers can add the predicate without an interface change when
|
|
59
59
|
* multi-site snapshots land.
|
|
60
60
|
*/
|
|
61
|
-
declare const scopeFor: (table: "pages" | "queries" | "countries" | "page_queries" | "dates" | "search_appearance" | "hourly_pages", opts: ScopedRunnerOptions) => TableScope, mergeScope: typeof import("@gscdump/engine/scope").mergeScope;
|
|
61
|
+
declare const scopeFor: (table: "pages" | "queries" | "countries" | "page_queries" | "dates" | "search_appearance" | "search_appearance_pages" | "search_appearance_queries" | "search_appearance_page_queries" | "hourly_pages", opts: ScopedRunnerOptions) => TableScope, mergeScope: typeof import("@gscdump/engine/scope").mergeScope;
|
|
62
62
|
interface StrikingMomentumOptions {
|
|
63
63
|
/** Anchor date (YYYY-MM-DD). Defaults to today. */
|
|
64
64
|
anchor?: string;
|
|
@@ -88,9 +88,11 @@ interface OpfsParquetFile {
|
|
|
88
88
|
/** Expected byte size — drives progress + a cheap pre-verify shortcut. */
|
|
89
89
|
bytes: number;
|
|
90
90
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
91
|
+
* Opaque content-stable identifier for this file (e.g. the Iceberg data-
|
|
92
|
+
* file object key). Encoded into the OPFS filename so the same hash is the
|
|
93
|
+
* same cache entry. NOT required to be a SHA-256. When omitted, the cache
|
|
94
|
+
* key falls back to `(table, index)` and only the byte size is verified
|
|
95
|
+
* (degraded — stale entries can survive a content change).
|
|
94
96
|
*/
|
|
95
97
|
contentHash?: string;
|
|
96
98
|
/** Row count — diagnostics only. */
|
|
@@ -258,6 +260,26 @@ interface AttachParquetUrlTablesOptions {
|
|
|
258
260
|
maxBytes?: number;
|
|
259
261
|
/** Abort signal passed through to URL preflights and registration. */
|
|
260
262
|
signal?: AbortSignal;
|
|
263
|
+
/**
|
|
264
|
+
* How DuckDB reads the parquet bytes:
|
|
265
|
+
* - `'http'` (default): register the URL as an HTTP file; DuckDB issues
|
|
266
|
+
* its own range reads during query execution. Right for large parquet
|
|
267
|
+
* where only some column chunks are touched per query.
|
|
268
|
+
* - `'buffer'`: fetch the full file once into an `ArrayBuffer` and register
|
|
269
|
+
* it via `registerFileBuffer`. DuckDB makes zero HTTP calls after
|
|
270
|
+
* registration — every query reads from the in-memory copy. Right for
|
|
271
|
+
* tiny files (e.g. `dates` daily-totals parquet, <50 KB per file) where
|
|
272
|
+
* the per-file round-trip overhead dominates the actual bytes moved.
|
|
273
|
+
*/
|
|
274
|
+
attachMode?: 'http' | 'buffer';
|
|
275
|
+
/**
|
|
276
|
+
* When `true` (default), skip per-file HEAD/Range preflight if the URL
|
|
277
|
+
* carries a signed `?s=<bytes>.<sig>` size hint — the size is already
|
|
278
|
+
* known and DuckDB will learn `Accept-Ranges` from its first read.
|
|
279
|
+
* Eliminates one round-trip per file on hosts that mint size hints. Set
|
|
280
|
+
* `false` to force preflight against URLs whose size you don't trust.
|
|
281
|
+
*/
|
|
282
|
+
trustSizeHint?: boolean;
|
|
261
283
|
/**
|
|
262
284
|
* Manifest version the caller associates with this set of URLs. Returned
|
|
263
285
|
* on the resulting handle so callers can compare against a fresh manifest
|
package/dist/index.mjs
CHANGED
|
@@ -399,6 +399,18 @@ var OpfsQuotaExceededError = class extends Error {
|
|
|
399
399
|
};
|
|
400
400
|
const DEFAULT_CONCURRENCY = 2;
|
|
401
401
|
const OPFS_PREFIX = "gscdump-snapshot__";
|
|
402
|
+
const dbFileRegistrations = /* @__PURE__ */ new WeakMap();
|
|
403
|
+
function isAlreadyRegistered(db, name) {
|
|
404
|
+
return dbFileRegistrations.get(db)?.has(name) === true;
|
|
405
|
+
}
|
|
406
|
+
function markRegistered(db, name) {
|
|
407
|
+
let set = dbFileRegistrations.get(db);
|
|
408
|
+
if (!set) {
|
|
409
|
+
set = /* @__PURE__ */ new Set();
|
|
410
|
+
dbFileRegistrations.set(db, set);
|
|
411
|
+
}
|
|
412
|
+
set.add(name);
|
|
413
|
+
}
|
|
402
414
|
function isQuotaError(err) {
|
|
403
415
|
if (typeof err !== "object" || err === null) return false;
|
|
404
416
|
return err.name === "QuotaExceededError" || err.code === 22;
|
|
@@ -406,8 +418,12 @@ function isQuotaError(err) {
|
|
|
406
418
|
function isAbortError$1(err) {
|
|
407
419
|
return typeof err === "object" && err !== null && err.name === "AbortError";
|
|
408
420
|
}
|
|
409
|
-
function opfsFileName(table, index) {
|
|
410
|
-
|
|
421
|
+
function opfsFileName(table, index, hashSlug) {
|
|
422
|
+
const base = `${OPFS_PREFIX}${table}_${index}`;
|
|
423
|
+
return hashSlug ? `${base}_${hashSlug}.parquet` : `${base}.parquet`;
|
|
424
|
+
}
|
|
425
|
+
function opfsFileNamePrefix(table, index) {
|
|
426
|
+
return `${OPFS_PREFIX}${table}_${index}`;
|
|
411
427
|
}
|
|
412
428
|
async function requestPersistentStorage() {
|
|
413
429
|
const storage = globalThis.navigator?.storage;
|
|
@@ -424,32 +440,34 @@ async function estimateOpfsStorage() {
|
|
|
424
440
|
quotaBytes: est.quota
|
|
425
441
|
} : {};
|
|
426
442
|
}
|
|
427
|
-
async function
|
|
428
|
-
const digest = await globalThis.crypto.subtle.digest("SHA-256",
|
|
429
|
-
|
|
443
|
+
async function contentHashSlug(contentHash) {
|
|
444
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(contentHash));
|
|
445
|
+
const bytes = new Uint8Array(digest);
|
|
446
|
+
let hex = "";
|
|
447
|
+
for (let i = 0; i < 8; i++) hex += bytes[i].toString(16).padStart(2, "0");
|
|
448
|
+
return hex;
|
|
430
449
|
}
|
|
431
450
|
async function getOpfsRoot() {
|
|
432
451
|
const dir = globalThis.navigator?.storage?.getDirectory;
|
|
433
452
|
if (!dir) throw new Error("[engine-duckdb-wasm/opfs] OPFS unavailable: navigator.storage.getDirectory missing");
|
|
434
453
|
return dir.call(globalThis.navigator.storage);
|
|
435
454
|
}
|
|
436
|
-
async function materialiseFile(root, name, file, fetchImpl, fetchInit, signal) {
|
|
455
|
+
async function materialiseFile(root, name, staleSweepPrefix, file, fetchImpl, fetchInit, signal) {
|
|
437
456
|
signal?.throwIfAborted();
|
|
438
457
|
let handle;
|
|
439
458
|
try {
|
|
440
459
|
handle = await root.getFileHandle(name);
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
outcome: "cache-hit"
|
|
446
|
-
};
|
|
447
|
-
if (await sha256Hex(await cached.arrayBuffer()) === file.contentHash.toLowerCase()) return {
|
|
448
|
-
handle,
|
|
449
|
-
outcome: "cache-hit"
|
|
450
|
-
};
|
|
451
|
-
}
|
|
460
|
+
if ((await handle.getFile()).size === file.bytes) return {
|
|
461
|
+
handle,
|
|
462
|
+
outcome: "cache-hit"
|
|
463
|
+
};
|
|
452
464
|
} catch {}
|
|
465
|
+
const dir = root;
|
|
466
|
+
if (dir.keys) for await (const existing of dir.keys()) {
|
|
467
|
+
if (existing === name || !existing.startsWith(staleSweepPrefix) || !existing.endsWith(".parquet")) continue;
|
|
468
|
+
const next = existing.charAt(staleSweepPrefix.length);
|
|
469
|
+
if (next === "." || next === "_") await root.removeEntry(existing).catch(() => {});
|
|
470
|
+
}
|
|
453
471
|
signal?.throwIfAborted();
|
|
454
472
|
const resp = await fetchImpl(file.url, {
|
|
455
473
|
...fetchInit,
|
|
@@ -457,10 +475,6 @@ async function materialiseFile(root, name, file, fetchImpl, fetchInit, signal) {
|
|
|
457
475
|
});
|
|
458
476
|
if (!resp.ok) throw new Error(`[engine-duckdb-wasm/opfs] download ${file.url} failed: ${resp.status}`);
|
|
459
477
|
const buf = await resp.arrayBuffer();
|
|
460
|
-
if (file.contentHash) {
|
|
461
|
-
const hash = await sha256Hex(buf);
|
|
462
|
-
if (hash !== file.contentHash.toLowerCase()) throw new Error(`[engine-duckdb-wasm/opfs] content-hash mismatch for ${file.url}: expected ${file.contentHash}, got ${hash}`);
|
|
463
|
-
}
|
|
464
478
|
handle = await root.getFileHandle(name, { create: true });
|
|
465
479
|
const writable = await handle.createWritable();
|
|
466
480
|
try {
|
|
@@ -513,10 +527,12 @@ async function attachOpfsParquetTables(options) {
|
|
|
513
527
|
await runWithConcurrency$1(flat, Math.max(1, fetchConcurrency), async (item, index) => {
|
|
514
528
|
if (degraded.has(item.table)) return;
|
|
515
529
|
signal?.throwIfAborted();
|
|
516
|
-
const
|
|
530
|
+
const hashSlug = item.file.contentHash ? await contentHashSlug(item.file.contentHash) : void 0;
|
|
531
|
+
const name = opfsFileName(item.table, item.fileIndex, hashSlug);
|
|
532
|
+
const sweepPrefix = opfsFileNamePrefix(item.table, item.fileIndex);
|
|
517
533
|
let result;
|
|
518
534
|
try {
|
|
519
|
-
result = await materialiseFile(root, name, item.file, fetchImpl, fetchInit, signal);
|
|
535
|
+
result = await materialiseFile(root, name, sweepPrefix, item.file, fetchImpl, fetchInit, signal);
|
|
520
536
|
} catch (err) {
|
|
521
537
|
if (isAbortError$1(err)) throw err;
|
|
522
538
|
if (isQuotaError(err)) {
|
|
@@ -525,7 +541,10 @@ async function attachOpfsParquetTables(options) {
|
|
|
525
541
|
}
|
|
526
542
|
throw err;
|
|
527
543
|
}
|
|
528
|
-
|
|
544
|
+
if (!isAlreadyRegistered(db, name)) {
|
|
545
|
+
await db.registerFileHandle(name, result.handle, DuckDBDataProtocol.BROWSER_FSACCESS, true);
|
|
546
|
+
markRegistered(db, name);
|
|
547
|
+
}
|
|
529
548
|
const list = tableFiles.get(item.table) ?? [];
|
|
530
549
|
list.push({
|
|
531
550
|
name,
|
|
@@ -575,9 +594,8 @@ async function attachOpfsParquetTables(options) {
|
|
|
575
594
|
}
|
|
576
595
|
};
|
|
577
596
|
}
|
|
578
|
-
async function detachOpfs(db, conn, schema, tables,
|
|
597
|
+
async function detachOpfs(db, conn, schema, tables, _files) {
|
|
579
598
|
for (const table of tables) await conn.query(`DROP VIEW IF EXISTS ${schema}.${table}`).catch(() => {});
|
|
580
|
-
if (files.length > 0) await db.dropFiles([...files]).catch(() => {});
|
|
581
599
|
}
|
|
582
600
|
async function clearOpfsSnapshotCache() {
|
|
583
601
|
const root = await getOpfsRoot().catch(() => null);
|
|
@@ -725,9 +743,9 @@ function rangeOnlyConfig(config) {
|
|
|
725
743
|
return {
|
|
726
744
|
...config ?? {},
|
|
727
745
|
filesystem: {
|
|
728
|
-
...config?.filesystem ?? {},
|
|
729
746
|
reliableHeadRequests: true,
|
|
730
747
|
allowFullHTTPReads: false,
|
|
748
|
+
...config?.filesystem ?? {},
|
|
731
749
|
forceFullHTTPReads: false
|
|
732
750
|
}
|
|
733
751
|
};
|
|
@@ -764,7 +782,7 @@ async function attachParquetTables(options) {
|
|
|
764
782
|
}
|
|
765
783
|
}
|
|
766
784
|
async function attachParquetUrlTables(options) {
|
|
767
|
-
const { db, conn, tables, fetch: fetchImpl = globalThis.fetch.bind(globalThis), schema = "main", fetchInit, fetchConcurrency, maxFiles, maxBytes, signal, version, onFileAttached } = options;
|
|
785
|
+
const { db, conn, tables, fetch: fetchImpl = globalThis.fetch.bind(globalThis), schema = "main", fetchInit, fetchConcurrency, maxFiles, maxBytes, signal, version, onFileAttached, attachMode = "http", trustSizeHint = true } = options;
|
|
768
786
|
const concurrency = positiveInteger(fetchConcurrency, DEFAULT_ATTACH_FETCH_CONCURRENCY, "fetchConcurrency");
|
|
769
787
|
const fileBudget = positiveInteger(maxFiles, DEFAULT_ATTACH_MAX_FILES, "maxFiles");
|
|
770
788
|
const byteBudget = positiveInteger(maxBytes, DEFAULT_ATTACH_MAX_BYTES, "maxBytes");
|
|
@@ -791,11 +809,21 @@ async function attachParquetUrlTables(options) {
|
|
|
791
809
|
const effectiveSignal = mergeAbortSignals(signal, budgetController.signal);
|
|
792
810
|
let plannedBytes = 0;
|
|
793
811
|
const total = flat.length;
|
|
794
|
-
const
|
|
812
|
+
const prepared = [];
|
|
795
813
|
await runWithConcurrency(flat, concurrency, async ({ table, url, index }) => {
|
|
796
814
|
if (tableFailures.has(table)) return;
|
|
797
815
|
effectiveSignal?.throwIfAborted();
|
|
798
|
-
|
|
816
|
+
try {
|
|
817
|
+
let bytes = null;
|
|
818
|
+
let body = null;
|
|
819
|
+
if (attachMode === "buffer") {
|
|
820
|
+
const res = await fetchImpl(url, fetchInitFor(fetchInit, "GET", effectiveSignal));
|
|
821
|
+
if (!res.ok) throw new Error(`GET ${url} failed: ${res.status}`);
|
|
822
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
823
|
+
body = buf;
|
|
824
|
+
bytes = buf.byteLength;
|
|
825
|
+
} else if (trustSizeHint && sizeHintFromUrl(url) !== null) bytes = sizeHintFromUrl(url);
|
|
826
|
+
else bytes = await preflightHttpUrl(url, fetchImpl, fetchInit, effectiveSignal);
|
|
799
827
|
plannedBytes += bytes;
|
|
800
828
|
if (plannedBytes > byteBudget) {
|
|
801
829
|
const err = new BrowserAttachBudgetExceededError(`browser parquet attach planned ${plannedBytes} bytes, above maxBytes=${byteBudget}`);
|
|
@@ -803,25 +831,28 @@ async function attachParquetUrlTables(options) {
|
|
|
803
831
|
throw err;
|
|
804
832
|
}
|
|
805
833
|
effectiveSignal?.throwIfAborted();
|
|
806
|
-
|
|
834
|
+
prepared.push({
|
|
807
835
|
table,
|
|
808
836
|
url,
|
|
809
837
|
index,
|
|
810
|
-
name: attachFileName(attachId, table, index)
|
|
838
|
+
name: attachFileName(attachId, table, index),
|
|
839
|
+
bytes,
|
|
840
|
+
body
|
|
811
841
|
});
|
|
812
|
-
}
|
|
842
|
+
} catch (err) {
|
|
813
843
|
if (effectiveSignal?.aborted || err instanceof BrowserAttachBudgetExceededError || isAbortError(err)) throw err;
|
|
814
844
|
tableFailures.set(table, err instanceof Error ? err : new Error(String(err)));
|
|
815
|
-
}
|
|
845
|
+
}
|
|
816
846
|
});
|
|
817
847
|
const { DuckDBDataProtocol } = await import("@duckdb/duckdb-wasm");
|
|
818
848
|
const attached = [];
|
|
819
849
|
const registeredFiles = [];
|
|
820
850
|
try {
|
|
821
|
-
for (const file of
|
|
851
|
+
for (const file of prepared) {
|
|
822
852
|
if (tableFailures.has(file.table)) continue;
|
|
823
853
|
effectiveSignal?.throwIfAborted();
|
|
824
|
-
await db.
|
|
854
|
+
if (file.body !== null) await db.registerFileBuffer(file.name, file.body);
|
|
855
|
+
else await db.registerFileURL(file.name, file.url, DuckDBDataProtocol.HTTP, false);
|
|
825
856
|
registeredFiles.push(file.name);
|
|
826
857
|
onFileAttached?.({
|
|
827
858
|
table: file.table,
|
|
@@ -831,7 +862,7 @@ async function attachParquetUrlTables(options) {
|
|
|
831
862
|
}
|
|
832
863
|
for (const table of Object.keys(counts)) {
|
|
833
864
|
if (tableFailures.has(table)) continue;
|
|
834
|
-
const files =
|
|
865
|
+
const files = prepared.filter((file) => file.table === table).sort((a, b) => a.index - b.index);
|
|
835
866
|
if (files.length !== counts[table]) continue;
|
|
836
867
|
effectiveSignal?.throwIfAborted();
|
|
837
868
|
await conn.query(readParquetViewSql(schema, table, files.map((file) => file.name)));
|
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.22.4",
|
|
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,13 +45,13 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"drizzle-orm": "^0.45.2",
|
|
48
|
-
"@gscdump/engine": "0.
|
|
49
|
-
"@gscdump/sdk": "0.
|
|
50
|
-
"gscdump": "0.
|
|
48
|
+
"@gscdump/engine": "0.22.4",
|
|
49
|
+
"@gscdump/sdk": "0.22.4",
|
|
50
|
+
"gscdump": "0.22.4"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@duckdb/duckdb-wasm": "^1.32.0",
|
|
54
|
-
"vitest": "^4.1.
|
|
54
|
+
"vitest": "^4.1.7"
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "obuild",
|