@gscdump/engine-duckdb-wasm 0.31.10 → 0.32.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
@@ -154,6 +154,17 @@ interface AttachOpfsTablesOptions {
154
154
  * Default: run inline (caller owns the DB exclusively).
155
155
  */
156
156
  withDb?: <T>(fn: () => Promise<T>) => Promise<T>;
157
+ /**
158
+ * Recover a table degraded by an OPFS sync-access-handle / write conflict
159
+ * (the multi-tab case — another tab holds the file's exclusive handle) by
160
+ * reading the already-cached bytes via the lock-free File API (or HTTP on a
161
+ * genuine cache miss) and registering them as in-memory buffers, so the table
162
+ * attaches from the shared cache instead of being pushed back to the caller.
163
+ * Quota / incomplete degradations are NEVER recovered this way — buffering a
164
+ * quota-exceeding set risks OOM, and the caller routes those to its tail.
165
+ * Default true.
166
+ */
167
+ recoverContention?: boolean;
157
168
  }
158
169
  interface OpfsFileProgress {
159
170
  table: string;
@@ -205,6 +216,20 @@ declare function estimateOpfsStorage(): Promise<{
205
216
  usageBytes?: number;
206
217
  quotaBytes?: number;
207
218
  }>;
219
+ /**
220
+ * Read a content-addressed OPFS snapshot file via the async File API and return
221
+ * its bytes, or null when absent / size-mismatched (partial / stale write).
222
+ *
223
+ * `getFile()` takes NO lock — unlike DuckDB's `BROWSER_FSACCESS` sync access
224
+ * handle — so it reads cleanly while ANOTHER tab holds the same file open. That
225
+ * is the basis for cross-tab cache sharing: a tab that loses the exclusive-handle
226
+ * race reads the shared cached bytes here instead of re-downloading. The
227
+ * filename derivation is the SAME `opfsFileName` + `contentHashSlug` the attach
228
+ * path writes, so consumers reuse the engine's naming with no replicated slug
229
+ * logic to drift out of lock-step. `index` is only the disambiguator for the
230
+ * degraded no-`contentHash` fallback (mirrors `attachOpfsParquetTables`).
231
+ */
232
+ declare function readOpfsSnapshotFile(table: string, contentHash: string | undefined, index: number, expectedBytes: number): Promise<Uint8Array | null>;
208
233
  /**
209
234
  * Download every parquet file in `tables` into OPFS, content-hash verify, and
210
235
  * attach them as DuckDB-WASM views. Attach-once: call this once per
@@ -459,4 +484,4 @@ declare function createBrowserAnalysisRuntime(boot: DuckDBWasmBootResult, option
459
484
  version?: number | string;
460
485
  attachedTables?: readonly string[];
461
486
  }): BrowserAnalysisRuntime;
462
- export { type AnalyzeResult, type AttachOpfsTablesOptions, type AttachParquetTablesOptions, type AttachParquetUrlTablesOptions, type AttachedTablesHandle, type BootDuckDBWasmOptions, type BrowserAnalysisRuntime, BrowserAttachBudgetExceededError, type BrowserAttachError, type BrowserParquetFile, type BrowserParquetTable, type BrowserParquetUrlTable, type ComparisonMode, type CompiledArchetypeSql, type DuckDBWasmBootResult, type DuckDBWasmClient, DuckDBWasmDatabase, type DuckDBWasmDrizzleDatabase, type InsightRunner, type InsightRunnerOptions, type OpfsAttachedHandle, type OpfsFileProgress, type OpfsParquetFile, type OpfsParquetTable, OpfsQuotaExceededError, type QueryResult, type ResolveWindowOptions, type ResolvedWindow, type Schema, type ScopedRunnerOptions, type StrikingMomentumOptions, type StrikingMomentumRow, type TableScope, type WindowPreset, attachOpfsParquetTables, attachParquetTables, attachParquetUrlTables, attachParquetUrlTablesResult, bootDuckDBWasm, browserAttachErrors, clearOpfsSnapshotCache, compileArchetypeSql, countries, createBrowserAnalysisRuntime, createClient, createInsightRunner, dates, drizzle, estimateOpfsStorage, hourly_pages, isBrowserAttachError, mergeScope, overlayViewBody, page_queries, pages, queries, requestPersistentStorage, resolveWindow, schema, scopeFor, strikingMomentum, tableForArchetype };
487
+ export { type AnalyzeResult, type AttachOpfsTablesOptions, type AttachParquetTablesOptions, type AttachParquetUrlTablesOptions, type AttachedTablesHandle, type BootDuckDBWasmOptions, type BrowserAnalysisRuntime, BrowserAttachBudgetExceededError, type BrowserAttachError, type BrowserParquetFile, type BrowserParquetTable, type BrowserParquetUrlTable, type ComparisonMode, type CompiledArchetypeSql, type DuckDBWasmBootResult, type DuckDBWasmClient, DuckDBWasmDatabase, type DuckDBWasmDrizzleDatabase, type InsightRunner, type InsightRunnerOptions, type OpfsAttachedHandle, type OpfsFileProgress, type OpfsParquetFile, type OpfsParquetTable, OpfsQuotaExceededError, type QueryResult, type ResolveWindowOptions, type ResolvedWindow, type Schema, type ScopedRunnerOptions, type StrikingMomentumOptions, type StrikingMomentumRow, type TableScope, type WindowPreset, attachOpfsParquetTables, attachParquetTables, attachParquetUrlTables, attachParquetUrlTablesResult, bootDuckDBWasm, browserAttachErrors, clearOpfsSnapshotCache, compileArchetypeSql, countries, createBrowserAnalysisRuntime, createClient, createInsightRunner, dates, drizzle, estimateOpfsStorage, hourly_pages, isBrowserAttachError, mergeScope, overlayViewBody, page_queries, pages, queries, readOpfsSnapshotFile, requestPersistentStorage, resolveWindow, schema, scopeFor, strikingMomentum, tableForArchetype };
package/dist/index.mjs CHANGED
@@ -566,6 +566,7 @@ var OpfsQuotaExceededError = class extends Error {
566
566
  };
567
567
  const DEFAULT_CONCURRENCY = 2;
568
568
  const OPFS_PREFIX = "gscdump-snapshot__";
569
+ const RECOVER_BUFFER_PREFIX = "gscdump-recover__";
569
570
  const dbRegistries = /* @__PURE__ */ new WeakMap();
570
571
  function getOpfsRegistry(db, protocol) {
571
572
  let registry = dbRegistries.get(db);
@@ -646,6 +647,17 @@ async function getOpfsRoot() {
646
647
  if (!dir) throw new Error("[engine-duckdb-wasm/opfs] OPFS unavailable: navigator.storage.getDirectory missing");
647
648
  return dir.call(globalThis.navigator.storage);
648
649
  }
650
+ async function readOpfsSnapshotFile(table, contentHash, index, expectedBytes) {
651
+ try {
652
+ const root = await getOpfsRoot();
653
+ const name = opfsFileName(table, contentHash ? await contentHashSlug(contentHash) : void 0, index);
654
+ const file = await (await root.getFileHandle(name)).getFile();
655
+ if (file.size !== expectedBytes) return null;
656
+ return new Uint8Array(await file.arrayBuffer());
657
+ } catch {
658
+ return null;
659
+ }
660
+ }
649
661
  async function materialiseFile(root, name, file, fetchImpl, fetchInit, signal) {
650
662
  signal?.throwIfAborted();
651
663
  let handle;
@@ -712,7 +724,7 @@ async function runWithConcurrency$1(items, concurrency, fn) {
712
724
  await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
713
725
  }
714
726
  async function attachOpfsParquetTables(options) {
715
- const { db, conn, tables, schema = "main", fetch: fetchImpl = globalThis.fetch.bind(globalThis), fetchInit, fetchConcurrency = DEFAULT_CONCURRENCY, signal, version, onFileProgress, withDb = (fn) => fn() } = options;
727
+ const { db, conn, tables, schema = "main", fetch: fetchImpl = globalThis.fetch.bind(globalThis), fetchInit, fetchConcurrency = DEFAULT_CONCURRENCY, signal, version, onFileProgress, withDb = (fn) => fn(), recoverContention = true } = options;
716
728
  await requestPersistentStorage();
717
729
  const root = await getOpfsRoot();
718
730
  const flat = [];
@@ -751,7 +763,10 @@ async function attachOpfsParquetTables(options) {
751
763
  await sweepStaleEntries(root, registry, expectedByTable).catch(() => {});
752
764
  const tableFiles = /* @__PURE__ */ new Map();
753
765
  const degraded = /* @__PURE__ */ new Set();
766
+ const degradeReason = /* @__PURE__ */ new Map();
754
767
  const acquiredNames = /* @__PURE__ */ new Set();
768
+ const bufferFiles = [];
769
+ const bufferViews = [];
755
770
  let bytesAttached = 0;
756
771
  const releaseTable = async (table) => {
757
772
  const names = (tableFiles.get(table) ?? []).map((f) => f.name);
@@ -769,10 +784,12 @@ async function attachOpfsParquetTables(options) {
769
784
  if (isAbortError$1(err)) throw err;
770
785
  if (isQuotaError(err)) {
771
786
  degraded.add(item.table);
787
+ degradeReason.set(item.table, "quota");
772
788
  return;
773
789
  }
774
790
  if (isOpfsWriteConflict(err)) {
775
791
  degraded.add(item.table);
792
+ degradeReason.set(item.table, "contention");
776
793
  return;
777
794
  }
778
795
  throw err;
@@ -784,6 +801,7 @@ async function attachOpfsParquetTables(options) {
784
801
  if (isAbortError$1(err)) throw err;
785
802
  if (isOpfsAccessHandleConflict(err)) {
786
803
  degraded.add(item.table);
804
+ degradeReason.set(item.table, "contention");
787
805
  return;
788
806
  }
789
807
  throw err;
@@ -821,6 +839,7 @@ async function attachOpfsParquetTables(options) {
821
839
  const files = tableFiles.get(t.table) ?? [];
822
840
  if (files.length !== (expectedCount.get(t.table) ?? t.files.length)) {
823
841
  degraded.add(t.table);
842
+ degradeReason.set(t.table, degradeReason.get(t.table) ?? "incomplete");
824
843
  await releaseTable(t.table);
825
844
  continue;
826
845
  }
@@ -837,6 +856,7 @@ async function attachOpfsParquetTables(options) {
837
856
  if (isOpfsAccessHandleConflict(err)) {
838
857
  if (registry.viewRefs(`${schema}.${t.table}`) === 0) await withDb(() => conn.query(`DROP VIEW IF EXISTS ${schema}.${t.table}`)).catch(() => {});
839
858
  degraded.add(t.table);
859
+ degradeReason.set(t.table, "contention");
840
860
  await releaseTable(t.table);
841
861
  continue;
842
862
  }
@@ -847,10 +867,58 @@ async function attachOpfsParquetTables(options) {
847
867
  registry.acquireView(`${schema}.${t.table}`);
848
868
  for (const f of files) registeredNames.push(f.name);
849
869
  }
870
+ const recoverTableToBuffers = async (t) => {
871
+ const readOne = async (file, index) => {
872
+ const cached = await readOpfsSnapshotFile(t.table, file.contentHash, index, file.bytes);
873
+ if (cached) return cached;
874
+ signal?.throwIfAborted();
875
+ const resp = await fetchImpl(file.url, {
876
+ ...fetchInit,
877
+ signal
878
+ });
879
+ if (!resp.ok) throw new Error(`[engine-duckdb-wasm/opfs] recover ${file.url} failed: ${resp.status}`);
880
+ return new Uint8Array(await resp.arrayBuffer());
881
+ };
882
+ const lakeNames = [];
883
+ for (let i = 0; i < t.files.length; i++) {
884
+ const buf = await readOne(t.files[i], i);
885
+ const name = `${RECOVER_BUFFER_PREFIX}${t.table}_${i}.parquet`;
886
+ await withDb(() => db.registerFileBuffer(name, buf));
887
+ bufferFiles.push(name);
888
+ lakeNames.push(name);
889
+ bytesAttached += t.files[i].bytes;
890
+ }
891
+ let overlayName;
892
+ if (t.overlay) {
893
+ const buf = await readOne(t.overlay, t.files.length);
894
+ overlayName = `${RECOVER_BUFFER_PREFIX}${t.table}_overlay.parquet`;
895
+ await withDb(() => db.registerFileBuffer(overlayName, buf));
896
+ bufferFiles.push(overlayName);
897
+ bytesAttached += t.overlay.bytes;
898
+ }
899
+ const body = overlayViewBody({
900
+ lakeSelect: lakeNames.length ? lakeSelect(lakeNames) : null,
901
+ overlaySelect: overlayName ? `SELECT * REPLACE (CAST(date AS DATE) AS date) FROM read_parquet(['${overlayName.replace(/'/g, "''")}'], union_by_name = true)` : null,
902
+ materializeLake: false
903
+ });
904
+ if (!body) return false;
905
+ await withDb(() => conn.query(`CREATE OR REPLACE VIEW ${schema}.${t.table} AS ${body}`));
906
+ bufferViews.push(t.table);
907
+ return true;
908
+ };
909
+ if (recoverContention && !signal?.aborted) for (const t of tables) {
910
+ if (!degraded.has(t.table) || degradeReason.get(t.table) !== "contention") continue;
911
+ const before = bufferFiles.length;
912
+ if (await recoverTableToBuffers(t).catch(() => false)) degraded.delete(t.table);
913
+ else {
914
+ for (const n of bufferFiles.slice(before)) await withDb(() => db.dropFile(n)).catch(() => {});
915
+ bufferFiles.length = before;
916
+ }
917
+ }
850
918
  let detached = false;
851
919
  return {
852
920
  version,
853
- tables: attached,
921
+ tables: [...attached, ...bufferViews],
854
922
  schema,
855
923
  bytesAttached,
856
924
  degradedTables: [...degraded],
@@ -858,6 +926,8 @@ async function attachOpfsParquetTables(options) {
858
926
  if (detached) return;
859
927
  detached = true;
860
928
  await detachOpfs(registry, conn, schema, attached, registeredNames);
929
+ for (const v of bufferViews) await conn.query(`DROP VIEW IF EXISTS ${schema}.${v}`).catch(() => {});
930
+ for (const n of bufferFiles) await db.dropFile(n).catch(() => {});
861
931
  }
862
932
  };
863
933
  }
@@ -1307,4 +1377,4 @@ function createBrowserAnalysisRuntime(boot, options = {}) {
1307
1377
  }
1308
1378
  };
1309
1379
  }
1310
- export { BrowserAttachBudgetExceededError, DuckDBWasmDatabase, OpfsQuotaExceededError, attachOpfsParquetTables, attachParquetTables, attachParquetUrlTables, attachParquetUrlTablesResult, bootDuckDBWasm, browserAttachErrors, clearOpfsSnapshotCache, compileArchetypeSql, countries, createBrowserAnalysisRuntime, createClient, createInsightRunner, dates, drizzle, estimateOpfsStorage, hourly_pages, isBrowserAttachError, mergeScope, overlayViewBody, page_queries, pages, queries, requestPersistentStorage, resolveWindow, schema, scopeFor, strikingMomentum, tableForArchetype };
1380
+ export { BrowserAttachBudgetExceededError, DuckDBWasmDatabase, OpfsQuotaExceededError, attachOpfsParquetTables, attachParquetTables, attachParquetUrlTables, attachParquetUrlTablesResult, bootDuckDBWasm, browserAttachErrors, clearOpfsSnapshotCache, compileArchetypeSql, countries, createBrowserAnalysisRuntime, createClient, createInsightRunner, dates, drizzle, estimateOpfsStorage, hourly_pages, isBrowserAttachError, mergeScope, overlayViewBody, page_queries, pages, queries, readOpfsSnapshotFile, requestPersistentStorage, resolveWindow, schema, scopeFor, strikingMomentum, tableForArchetype };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/engine-duckdb-wasm",
3
3
  "type": "module",
4
- "version": "0.31.10",
4
+ "version": "0.32.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,15 +45,15 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "drizzle-orm": "1.0.0-rc.3",
48
- "@gscdump/contracts": "0.31.10",
49
- "@gscdump/engine": "0.31.10",
50
- "gscdump": "0.31.10"
48
+ "@gscdump/engine": "0.32.1",
49
+ "@gscdump/contracts": "0.32.1",
50
+ "gscdump": "0.32.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@duckdb/duckdb-wasm": "^1.32.0",
54
54
  "@vitest/browser": "^4.1.9",
55
55
  "@vitest/browser-playwright": "^4.1.9",
56
- "playwright": "^1.61.0",
56
+ "playwright": "^1.61.1",
57
57
  "vitest": "^4.1.9"
58
58
  },
59
59
  "scripts": {