@gscdump/engine-duckdb-wasm 0.32.11 → 0.33.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.
Files changed (2) hide show
  1. package/dist/index.mjs +92 -27
  2. package/package.json +4 -4
package/dist/index.mjs CHANGED
@@ -20,11 +20,16 @@ const METRIC_SQL = {
20
20
  const DIM_COLUMN = {
21
21
  page: "url",
22
22
  query: "query",
23
- queryCanonical: "query_canonical",
24
23
  country: "country",
25
24
  date: "date",
26
25
  searchAppearance: "search_appearance"
27
26
  };
27
+ function queryCanonicalExpr(queryRef = "query") {
28
+ return `COALESCE((SELECT qd.query_canonical FROM query_dim qd WHERE qd.query = ${queryRef} LIMIT 1), ${queryRef})`;
29
+ }
30
+ function dimExpr(dim) {
31
+ return dim === "queryCanonical" ? queryCanonicalExpr() : DIM_COLUMN[dim] ?? dim;
32
+ }
28
33
  function tableForDimensions(dims) {
29
34
  const set = new Set(dims.filter((d) => d !== "date"));
30
35
  if (set.has("page") && (set.has("query") || set.has("queryCanonical"))) return "page_queries";
@@ -128,7 +133,7 @@ function facetPredicate(query) {
128
133
  const parts = [];
129
134
  const params = [];
130
135
  for (const f of facets) {
131
- const col = DIM_COLUMN[f.column] ?? f.column;
136
+ const col = dimExpr(f.column);
132
137
  switch (f.op) {
133
138
  case "eq":
134
139
  parts.push(`${col} = ?`);
@@ -163,7 +168,7 @@ function compileArchetypeSql(query) {
163
168
  }
164
169
  case "entity-daily-timeseries": {
165
170
  const table = tableForDimensions([query.entity.dimension]);
166
- const col = DIM_COLUMN[query.entity.dimension];
171
+ const col = dimExpr(query.entity.dimension);
167
172
  const where = rangePredicate(query);
168
173
  return {
169
174
  table,
@@ -173,7 +178,7 @@ function compileArchetypeSql(query) {
173
178
  }
174
179
  case "entity-daily-sparkline": {
175
180
  const table = tableForDimensions([query.dimension]);
176
- const col = DIM_COLUMN[query.dimension];
181
+ const col = dimExpr(query.dimension);
177
182
  const where = rangePredicate(query);
178
183
  if (query.entities.length === 0) throw new Error("[archetype-sql] entity-daily-sparkline requires resolved entities");
179
184
  const placeholders = query.entities.map(() => "?").join(", ");
@@ -231,7 +236,7 @@ function compileArchetypeSql(query) {
231
236
  params
232
237
  };
233
238
  }
234
- const col = DIM_COLUMN[query.dimension];
239
+ const col = dimExpr(query.dimension);
235
240
  const facet = facetPredicate(query);
236
241
  const totalCol = query.includeTotal ? ", COUNT(*) OVER() AS __total" : "";
237
242
  if (cmp) {
@@ -281,7 +286,7 @@ function compileArchetypeSql(query) {
281
286
  const matchParts = [];
282
287
  const matchParams = [];
283
288
  for (const [dim, value] of Object.entries(query.match)) {
284
- const col = DIM_COLUMN[dim];
289
+ const col = dimExpr(dim);
285
290
  if (!col) throw new Error(`[archetype-sql] single-row-lookup: unknown dimension ${dim}`);
286
291
  matchParts.push(`${col} = ?`);
287
292
  matchParams.push(value);
@@ -305,7 +310,7 @@ function compileArchetypeSql(query) {
305
310
  ...where.params
306
311
  ]
307
312
  };
308
- const col = DIM_COLUMN[query.seriesDimension];
313
+ const col = dimExpr(query.seriesDimension);
309
314
  return {
310
315
  table,
311
316
  sql: `SELECT date, ${col} AS ${query.seriesDimension}, ${metricExpr(query.metric)} AS ${query.metric} FROM ${table} WHERE ${where.sql} GROUP BY date, ${col} ORDER BY date, ${col}`,
@@ -525,13 +530,26 @@ function createOpfsHandleRegistry(backend) {
525
530
  }
526
531
  }
527
532
  const viewRefs = /* @__PURE__ */ new Map();
528
- function acquireView(key) {
529
- viewRefs.set(key, (viewRefs.get(key) ?? 0) + 1);
533
+ function acquireView(key, signature) {
534
+ const existing = viewRefs.get(key);
535
+ if (existing) {
536
+ if (existing.signature !== signature) throw new Error(`view ${key} is already attached with a different signature`);
537
+ existing.refs++;
538
+ return;
539
+ }
540
+ viewRefs.set(key, {
541
+ refs: 1,
542
+ signature
543
+ });
530
544
  }
531
545
  async function releaseView(key, drop) {
532
- const next = (viewRefs.get(key) ?? 0) - 1;
546
+ const existing = viewRefs.get(key);
547
+ const next = (existing?.refs ?? 0) - 1;
533
548
  if (next > 0) {
534
- viewRefs.set(key, next);
549
+ viewRefs.set(key, {
550
+ refs: next,
551
+ signature: existing?.signature
552
+ });
535
553
  return;
536
554
  }
537
555
  viewRefs.delete(key);
@@ -544,7 +562,8 @@ function createOpfsHandleRegistry(backend) {
544
562
  refs: (name) => entries.get(name)?.refs ?? 0,
545
563
  acquireView,
546
564
  releaseView,
547
- viewRefs: (key) => viewRefs.get(key) ?? 0
565
+ viewRefs: (key) => viewRefs.get(key)?.refs ?? 0,
566
+ viewSignature: (key) => viewRefs.get(key)?.signature
548
567
  };
549
568
  }
550
569
  function overlayViewBody(args) {
@@ -569,6 +588,7 @@ const OPFS_PREFIX = "gscdump-snapshot__";
569
588
  const RECOVER_BUFFER_PREFIX = "gscdump-recover__";
570
589
  const MAX_CONTENT_HASH_SLUG_CACHE = 4096;
571
590
  const contentHashSlugCache = /* @__PURE__ */ new Map();
591
+ const IDENT_RE = /^[A-Z_][\w$]*$/i;
572
592
  function nowMs() {
573
593
  return globalThis.performance?.now?.() ?? Date.now();
574
594
  }
@@ -603,6 +623,23 @@ function getOpfsRegistry(db, protocol) {
603
623
  }
604
624
  return registry;
605
625
  }
626
+ const dbBufferRegistries = /* @__PURE__ */ new WeakMap();
627
+ function getBufferRegistry(db) {
628
+ let registry = dbBufferRegistries.get(db);
629
+ if (!registry) {
630
+ registry = createOpfsHandleRegistry({
631
+ register: (name, handle) => db.registerFileBuffer(name, handle),
632
+ drop: async (name) => {
633
+ await db.dropFile(name);
634
+ }
635
+ });
636
+ dbBufferRegistries.set(db, registry);
637
+ }
638
+ return registry;
639
+ }
640
+ function assertSqlIdentifier(kind, value) {
641
+ if (!IDENT_RE.test(value)) throw new TypeError(`[engine-duckdb-wasm/opfs] invalid ${kind} identifier ${JSON.stringify(value)}`);
642
+ }
606
643
  function isQuotaError(err) {
607
644
  if (typeof err !== "object" || err === null) return false;
608
645
  return err.name === "QuotaExceededError" || err.code === 22;
@@ -743,6 +780,28 @@ function readParquetViewWithOverlaySql(schema, table, lakeFiles, overlayFile) {
743
780
  materializeLake: true
744
781
  })}`;
745
782
  }
783
+ function viewKey(schema, table) {
784
+ return `${schema}.${table}`;
785
+ }
786
+ function viewSignature(lakeFiles, overlayFile) {
787
+ return JSON.stringify({
788
+ lake: [...lakeFiles].sort(),
789
+ overlay: overlayFile ?? null
790
+ });
791
+ }
792
+ async function ensureView(registry, conn, schema, table, signature, sql) {
793
+ const key = viewKey(schema, table);
794
+ if (registry.viewSignature(key) === signature) {
795
+ registry.acquireView(key, signature);
796
+ return;
797
+ }
798
+ if (registry.viewRefs(key) > 0) throw new Error(`[engine-duckdb-wasm/opfs] view ${key} is already attached with a different fileset`);
799
+ await conn.query(sql);
800
+ registry.acquireView(key, signature);
801
+ }
802
+ async function recoveredBufferName(table, file, index) {
803
+ return `${RECOVER_BUFFER_PREFIX}${table}_${file.contentHash ? await contentHashSlug(file.contentHash) : String(index)}.parquet`;
804
+ }
746
805
  async function runWithConcurrency$1(items, concurrency, fn) {
747
806
  let next = 0;
748
807
  let failed;
@@ -757,10 +816,13 @@ async function runWithConcurrency$1(items, concurrency, fn) {
757
816
  }
758
817
  }
759
818
  }
760
- await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
819
+ const rejected = (await Promise.allSettled(Array.from({ length: Math.min(concurrency, items.length) }, worker))).find((r) => r.status === "rejected");
820
+ if (rejected) throw rejected.reason;
761
821
  }
762
822
  async function attachOpfsParquetTables(options) {
763
823
  const { db, conn, tables, schema = "main", fetch: fetchImpl = globalThis.fetch.bind(globalThis), fetchInit, fetchConcurrency = DEFAULT_CONCURRENCY, signal, version, onFileProgress, onTiming, withDb = (fn) => fn(), recoverContention = true } = options;
824
+ assertSqlIdentifier("schema", schema);
825
+ for (const table of tables) assertSqlIdentifier("table", table.table);
764
826
  const totalStarted = nowMs();
765
827
  await timed(onTiming, "persist", {}, requestPersistentStorage);
766
828
  const root = await timed(onTiming, "root", {}, getOpfsRoot);
@@ -799,6 +861,7 @@ async function attachOpfsParquetTables(options) {
799
861
  const expectedCount = new Map(tables.map((t) => [t.table, t.files.length + (t.overlay ? 1 : 0)]));
800
862
  const { DuckDBDataProtocol } = await timed(onTiming, "duckdb-import", {}, () => import("@duckdb/duckdb-wasm"));
801
863
  const registry = getOpfsRegistry(db, DuckDBDataProtocol.BROWSER_FSACCESS);
864
+ const bufferRegistry = getBufferRegistry(db);
802
865
  await timed(onTiming, "sweep", {
803
866
  files: total,
804
867
  tables: tables.length
@@ -915,20 +978,21 @@ async function attachOpfsParquetTables(options) {
915
978
  continue;
916
979
  }
917
980
  const overlayName = overlayNames.get(t.table);
918
- const lakeNames = overlayName ? files.map((f) => f.name).filter((n) => n !== overlayName) : files.map((f) => f.name);
981
+ const lakeNames = (overlayName ? files.map((f) => f.name).filter((n) => n !== overlayName) : files.map((f) => f.name)).sort();
982
+ const signature = viewSignature(lakeNames, overlayName);
919
983
  try {
920
984
  signal?.throwIfAborted();
921
985
  await timed(onTiming, "view", {
922
986
  table: t.table,
923
987
  files: files.length
924
- }, () => withDb(() => conn.query(overlayName ? readParquetViewWithOverlaySql(schema, t.table, lakeNames, overlayName) : readParquetViewSql$1(schema, t.table, lakeNames))));
988
+ }, () => withDb(() => ensureView(registry, conn, schema, t.table, signature, overlayName ? readParquetViewWithOverlaySql(schema, t.table, lakeNames, overlayName) : readParquetViewSql$1(schema, t.table, lakeNames))));
925
989
  } catch (err) {
926
990
  if (isAbortError$1(err)) {
927
991
  await withDb(() => detachOpfs(registry, conn, schema, attached, [...acquiredNames])).catch(() => {});
928
992
  throw err;
929
993
  }
930
994
  if (isOpfsAccessHandleConflict(err)) {
931
- if (registry.viewRefs(`${schema}.${t.table}`) === 0) await withDb(() => conn.query(`DROP VIEW IF EXISTS ${schema}.${t.table}`)).catch(() => {});
995
+ if (registry.viewRefs(viewKey(schema, t.table)) === 0) await withDb(() => conn.query(`DROP VIEW IF EXISTS ${schema}.${t.table}`)).catch(() => {});
932
996
  degraded.add(t.table);
933
997
  degradeReason.set(t.table, "contention");
934
998
  await releaseTable(t.table);
@@ -938,7 +1002,6 @@ async function attachOpfsParquetTables(options) {
938
1002
  throw err;
939
1003
  }
940
1004
  attached.push(t.table);
941
- registry.acquireView(`${schema}.${t.table}`);
942
1005
  for (const f of files) registeredNames.push(f.name);
943
1006
  }
944
1007
  const recoverTableToBuffers = async (t) => {
@@ -959,28 +1022,30 @@ async function attachOpfsParquetTables(options) {
959
1022
  };
960
1023
  const lakeNames = [];
961
1024
  for (let i = 0; i < t.files.length; i++) {
962
- const buf = await readOne(t.files[i], i);
963
- const name = `${RECOVER_BUFFER_PREFIX}${t.table}_${i}.parquet`;
964
- await withDb(() => db.registerFileBuffer(name, buf));
1025
+ const file = t.files[i];
1026
+ const name = await recoveredBufferName(t.table, file, i);
1027
+ const buf = await readOne(file, i);
1028
+ await withDb(() => bufferRegistry.acquire(name, () => buf));
965
1029
  bufferFiles.push(name);
966
1030
  lakeNames.push(name);
967
- bytesAttached += t.files[i].bytes;
1031
+ bytesAttached += file.bytes;
968
1032
  }
969
1033
  let overlayName;
970
1034
  if (t.overlay) {
1035
+ overlayName = await recoveredBufferName(t.table, t.overlay, t.files.length);
971
1036
  const buf = await readOne(t.overlay, t.files.length);
972
- overlayName = `${RECOVER_BUFFER_PREFIX}${t.table}_overlay.parquet`;
973
- await withDb(() => db.registerFileBuffer(overlayName, buf));
1037
+ await withDb(() => bufferRegistry.acquire(overlayName, () => buf));
974
1038
  bufferFiles.push(overlayName);
975
1039
  bytesAttached += t.overlay.bytes;
976
1040
  }
1041
+ lakeNames.sort();
977
1042
  const body = overlayViewBody({
978
1043
  lakeSelect: lakeNames.length ? lakeSelect(lakeNames) : null,
979
1044
  overlaySelect: overlayName ? `SELECT * REPLACE (CAST(date AS DATE) AS date) FROM read_parquet(['${overlayName.replace(/'/g, "''")}'], union_by_name = true)` : null,
980
1045
  materializeLake: false
981
1046
  });
982
1047
  if (!body) return false;
983
- await withDb(() => conn.query(`CREATE OR REPLACE VIEW ${schema}.${t.table} AS ${body}`));
1048
+ await withDb(() => ensureView(registry, conn, schema, t.table, viewSignature(lakeNames, overlayName), `CREATE OR REPLACE VIEW ${schema}.${t.table} AS ${body}`));
984
1049
  bufferViews.push(t.table);
985
1050
  return true;
986
1051
  });
@@ -990,7 +1055,7 @@ async function attachOpfsParquetTables(options) {
990
1055
  const before = bufferFiles.length;
991
1056
  if (await recoverTableToBuffers(t).catch(() => false)) degraded.delete(t.table);
992
1057
  else {
993
- for (const n of bufferFiles.slice(before)) await withDb(() => db.dropFile(n)).catch(() => {});
1058
+ await withDb(() => bufferRegistry.release(bufferFiles.slice(before))).catch(() => {});
994
1059
  bufferFiles.length = before;
995
1060
  }
996
1061
  }
@@ -1012,8 +1077,8 @@ async function attachOpfsParquetTables(options) {
1012
1077
  if (detached) return;
1013
1078
  detached = true;
1014
1079
  await detachOpfs(registry, conn, schema, attached, registeredNames);
1015
- for (const v of bufferViews) await conn.query(`DROP VIEW IF EXISTS ${schema}.${v}`).catch(() => {});
1016
- for (const n of bufferFiles) await db.dropFile(n).catch(() => {});
1080
+ for (const v of bufferViews) await registry.releaseView(viewKey(schema, v), () => conn.query(`DROP VIEW IF EXISTS ${schema}.${v}`).then(() => {}));
1081
+ await bufferRegistry.release(bufferFiles);
1017
1082
  }
1018
1083
  };
1019
1084
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/engine-duckdb-wasm",
3
3
  "type": "module",
4
- "version": "0.32.11",
4
+ "version": "0.33.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,9 +45,9 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "drizzle-orm": "1.0.0-rc.3",
48
- "@gscdump/contracts": "0.32.11",
49
- "@gscdump/engine": "0.32.11",
50
- "gscdump": "0.32.11"
48
+ "gscdump": "0.33.0",
49
+ "@gscdump/engine": "0.33.0",
50
+ "@gscdump/contracts": "0.33.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@duckdb/duckdb-wasm": "^1.32.0",