@gscdump/engine-duckdb-wasm 0.21.2 → 0.22.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.
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
- * Lowercase hex SHA-256 of the file's bytes (the Iceberg data-file digest).
92
- * The OPFS-cached copy is verified against this before it is trusted.
93
- * When omitted, only the byte size is checked (degraded trust).
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
- return `${OPFS_PREFIX}${table}_${index}.parquet`;
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 sha256Hex(bytes) {
428
- const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
429
- return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
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
- const cached = await handle.getFile();
442
- if (cached.size === file.bytes) {
443
- if (!file.contentHash) return {
444
- handle,
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 name = opfsFileName(item.table, item.fileIndex);
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
- await db.registerFileHandle(name, result.handle, DuckDBDataProtocol.BROWSER_FSACCESS, true);
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, files) {
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 preflighted = [];
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
- await preflightHttpUrl(url, fetchImpl, fetchInit, effectiveSignal).then((bytes) => {
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
- preflighted.push({
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
- }).catch((err) => {
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 preflighted) {
851
+ for (const file of prepared) {
822
852
  if (tableFailures.has(file.table)) continue;
823
853
  effectiveSignal?.throwIfAborted();
824
- await db.registerFileURL(file.name, file.url, DuckDBDataProtocol.HTTP, false);
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 = preflighted.filter((file) => file.table === table).sort((a, b) => a.index - b.index);
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.21.2",
4
+ "version": "0.22.1",
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.21.2",
49
- "@gscdump/sdk": "0.21.2",
50
- "gscdump": "0.21.2"
48
+ "gscdump": "0.22.1",
49
+ "@gscdump/engine": "0.22.1",
50
+ "@gscdump/sdk": "0.22.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@duckdb/duckdb-wasm": "^1.32.0",
54
- "vitest": "^4.1.6"
54
+ "vitest": "^4.1.7"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "obuild",